diff --git a/.ai/memory/architecture/activex.md b/.ai/memory/architecture/activex.md index ae641de..b637630 100644 --- a/.ai/memory/architecture/activex.md +++ b/.ai/memory/architecture/activex.md @@ -26,6 +26,9 @@ The ActiveX rendering coordinator selects a backend for a Core render scene. GDI is the CPU fallback; OpenGL has a narrower supported-scene range and can fall back to GDI. Backends consume Core-produced geometry and do not own datasets or view state. The OCX owns the host-specific rendering resources. +The control composes every frame in an offscreen DIB-section `FrameBuffer` +and presents it with a single `BitBlt`; `WM_ERASEBKGND` is suppressed because +`OnDraw` paints an opaque frame. Core objects are not thread-safe by default. The control serializes their use. ActiveX-specific implementation units are also compiled into diff --git a/src/MapWinGIS.ActiveX/FrameBuffer.cpp b/src/MapWinGIS.ActiveX/FrameBuffer.cpp new file mode 100644 index 0000000..a476322 --- /dev/null +++ b/src/MapWinGIS.ActiveX/FrameBuffer.cpp @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file FrameBuffer.cpp + * @brief Implements the offscreen frame buffer. + */ +#include "FrameBuffer.h" + +namespace mwg::activex +{ + namespace + { + [[nodiscard]] HBITMAP CreateBufferBitmap( + const HDC deviceContext, + const int width, + const int height) noexcept + { + BITMAPINFO bitmapInfo{}; + bitmapInfo.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bitmapInfo.bmiHeader.biWidth = width; + bitmapInfo.bmiHeader.biHeight = -height; // top-down rows + bitmapInfo.bmiHeader.biPlanes = 1; + bitmapInfo.bmiHeader.biBitCount = 32; + bitmapInfo.bmiHeader.biCompression = BI_RGB; + + void* bits = nullptr; + return CreateDIBSection(deviceContext, &bitmapInfo, DIB_RGB_COLORS, &bits, nullptr, 0); + } + } + + FrameBuffer::~FrameBuffer() + { + Release(); + } + + bool FrameBuffer::Resize(const int width, const int height) noexcept + { + if (width <= 0 || height <= 0) + { + return false; + } + + if (IsValid() && width == _width && height == _height) + { + return true; + } + + // Allocate the replacement before releasing the current buffer so a + // failed resize keeps a presentable buffer. + HDC memoryDc = CreateCompatibleDC(nullptr); + if (memoryDc == nullptr) + { + ::OutputDebugStringW(L"MapWinGIS: could not create the frame buffer device context.\n"); + return false; + } + + const HBITMAP bitmap = CreateBufferBitmap(memoryDc, width, height); + if (bitmap == nullptr) + { + ::OutputDebugStringW(L"MapWinGIS: could not allocate the frame buffer bitmap.\n"); + DeleteDC(memoryDc); + return false; + } + + const HBITMAP previousBitmap = static_cast(SelectObject(memoryDc, bitmap)); + if (previousBitmap == nullptr || previousBitmap == HGDI_ERROR) + { + ::OutputDebugStringW(L"MapWinGIS: could not select the frame buffer bitmap.\n"); + DeleteObject(bitmap); + DeleteDC(memoryDc); + return false; + } + + Release(); + _memoryDc = memoryDc; + _bitmap = bitmap; + _previousBitmap = previousBitmap; + _width = width; + _height = height; + return true; + } + + void FrameBuffer::Release() noexcept + { + if (_memoryDc != nullptr) + { + if (_previousBitmap != nullptr && _previousBitmap != HGDI_ERROR) + { + SelectObject(_memoryDc, _previousBitmap); + _previousBitmap = nullptr; + } + DeleteDC(_memoryDc); + _memoryDc = nullptr; + } + + if (_bitmap != nullptr) + { + DeleteObject(_bitmap); + _bitmap = nullptr; + } + + _width = 0; + _height = 0; + } +} diff --git a/src/MapWinGIS.ActiveX/FrameBuffer.h b/src/MapWinGIS.ActiveX/FrameBuffer.h new file mode 100644 index 0000000..4b6b5ec --- /dev/null +++ b/src/MapWinGIS.ActiveX/FrameBuffer.h @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file FrameBuffer.h + * @brief Owns a resizable offscreen device context for flicker-free painting. + * + * Backs a memory device context with a 32-bit DIB section so a complete + * frame can be composed offscreen and presented to the visible surface with + * a single blit. This is host-rendering infrastructure owned by the OCX + * layer; it holds no scene, dataset, or view state. + */ +#pragma once + +#include + +namespace mwg::activex +{ + class FrameBuffer final + { + public: + FrameBuffer() noexcept = default; + ~FrameBuffer(); + + FrameBuffer(const FrameBuffer&) = delete; + FrameBuffer& operator=(const FrameBuffer&) = delete; + + [[nodiscard]] HDC Get() const noexcept { return _memoryDc; } + [[nodiscard]] int Width() const noexcept { return _width; } + [[nodiscard]] int Height() const noexcept { return _height; } + + /// True when Get returns a device context with the bitmap selected. + [[nodiscard]] bool IsValid() const noexcept + { + return _memoryDc != nullptr && _bitmap != nullptr; + } + + /// Recreates the buffer when the size changed and keeps the current + /// bitmap for an equal size. Returns false for non-positive sizes + /// and allocation failures; a failed resize leaves the previous + /// buffer intact so it can still be presented. + [[nodiscard]] bool Resize(int width, int height) noexcept; + + /// Releases the device context and bitmap; the next Resize + /// recreates them. + void Release() noexcept; + + private: + HDC _memoryDc = nullptr; + HBITMAP _bitmap = nullptr; + HBITMAP _previousBitmap = nullptr; + int _width = 0; + int _height = 0; + }; +} diff --git a/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.Impl.vcxproj b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.Impl.vcxproj index 4934b96..d07f85f 100644 --- a/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.Impl.vcxproj +++ b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.Impl.vcxproj @@ -47,6 +47,7 @@ + @@ -57,6 +58,7 @@ + diff --git a/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.vcxproj b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.vcxproj index 7aa2377..be5eefe 100644 --- a/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.vcxproj +++ b/src/MapWinGIS.ActiveX/MapWinGIS.ActiveX.vcxproj @@ -67,6 +67,7 @@ + diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp index 79507dd..7fc8d3a 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.cpp +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.cpp @@ -7,8 +7,10 @@ * * Redraws: every map or view change made through the control goes through * its MapView; the control calls FireViewChange when the MapView reports - * that the frame changed. OnDraw draws the MapView's render scene through - * its viewport, so every layer uses the same transformation. + * that the frame changed. OnDraw composes the MapView's render scene through + * its viewport into an offscreen frame buffer and presents the finished + * frame with a single blit, so every layer uses the same transformation and + * the visible surface never shows a partially drawn frame. */ #include "MapWinGISControl.h" @@ -1041,46 +1043,106 @@ HRESULT CMapWinGISControl::OnDraw(ATL_DRAWINFO& drawInfo) drawInfo.prcBounds->right, drawInfo.prcBounds->bottom}; - if (::FillRect( - drawInfo.hdcDraw, - &bounds, - ::GetSysColorBrush(COLOR_WINDOW)) == 0) - { - return E_FAIL; - } - const LONGLONG width = static_cast(bounds.right) - bounds.left; const LONGLONG height = static_cast(bounds.bottom) - bounds.top; - if (width <= 0 || height <= 0 || - width > mwg::Viewport::kMaxDimension || height > mwg::Viewport::kMaxDimension) - { - return S_OK; - } - try - { - const mwg::rendering::MapRenderScene& scene = _view.Scene(); - // Printing and scaled draws use a snapshot, never the live viewport. - mwg::Viewport viewport = _view.GetViewport(); - viewport.SetSize(static_cast(width), static_cast(height)); - if (!viewport.HasView()) + const bool drawable = width > 0 && height > 0 && + width <= mwg::Viewport::kMaxDimension && height <= mwg::Viewport::kMaxDimension; + + // Compose the frame in an offscreen buffer and present it with a single + // blit, so the visible surface never shows the background erase or a + // partially drawn frame. Drawing straight to the target device context + // is the fallback for sizes the buffer cannot hold. + const bool buffered = drawable && + _frameBuffer.Resize( + static_cast(width), + static_cast(height)); + HDC frameDc = buffered ? _frameBuffer.Get() : drawInfo.hdcDraw; + RECT frameBounds = buffered + ? RECT{0, 0, static_cast(width), static_cast(height)} + : bounds; + + const auto renderFrame = [&]() -> HRESULT + { + if (::FillRect( + frameDc, + &frameBounds, + ::GetSysColorBrush(COLOR_WINDOW)) == 0) { - (void)viewport.FitExtent(_view.GetMap().GetExtent(), mwg::MapView::kDefaultPadding); + return E_FAIL; } - if (scene.layers.empty() || !viewport.IsValid()) + + if (!drawable) { return S_OK; } - return _renderer.Draw(drawInfo.hdcDraw, bounds, scene, viewport); - } - catch (const std::bad_alloc&) - { - return E_OUTOFMEMORY; - } - catch (const std::exception& exception) + + try + { + const mwg::rendering::MapRenderScene& scene = _view.Scene(); + // Printing and scaled draws use a snapshot, never the live viewport. + mwg::Viewport viewport = _view.GetViewport(); + viewport.SetSize(static_cast(width), static_cast(height)); + if (!viewport.HasView()) + { + (void)viewport.FitExtent(_view.GetMap().GetExtent(), mwg::MapView::kDefaultPadding); + } + if (!scene.layers.empty() && viewport.IsValid()) + { + return _renderer.Draw(frameDc, frameBounds, scene, viewport); + } + return S_OK; + } + catch (const std::bad_alloc&) + { + return E_OUTOFMEMORY; + } + catch (const std::exception& exception) + { + ::OutputDebugStringA(exception.what()); + return E_FAIL; + } + }; + + if (buffered) { - ::OutputDebugStringA(exception.what()); - return E_FAIL; + const HRESULT result = renderFrame(); + if (FAILED(result)) + { + return result; + } + if (::BitBlt( + drawInfo.hdcDraw, + bounds.left, + bounds.top, + static_cast(width), + static_cast(height), + frameDc, + 0, + 0, + SRCCOPY) != 0) + { + return result; + } + + // A blit between different devices (printer or metafile targets) + // can fail; present the frame by drawing straight to the target. + frameDc = drawInfo.hdcDraw; + frameBounds = bounds; } + + return renderFrame(); +} + +LRESULT CMapWinGISControl::OnEraseBkgnd( + UINT, + WPARAM, + LPARAM, + BOOL& handled) +{ + // OnDraw paints an opaque frame over the whole bounds, so erasing the + // background first would only flash the host's color through. + handled = TRUE; + return 1; } CMapWinGISControl::~CMapWinGISControl() diff --git a/src/MapWinGIS.ActiveX/MapWinGISControl.h b/src/MapWinGIS.ActiveX/MapWinGISControl.h index c3ff168..1e02e01 100644 --- a/src/MapWinGIS.ActiveX/MapWinGISControl.h +++ b/src/MapWinGIS.ActiveX/MapWinGISControl.h @@ -19,6 +19,7 @@ #include #include "mapwingis/map/MapView.h" +#include "FrameBuffer.h" #include "GdalEventSource.h" #include "VectorLayerRenderer.h" @@ -98,6 +99,7 @@ class ATL_NO_VTABLE CMapWinGISControl : MESSAGE_HANDLER(WM_CANCELMODE, OnCancelMode) MESSAGE_HANDLER(WM_SIZE, OnSize) MESSAGE_HANDLER(WM_DESTROY, OnDestroy) + MESSAGE_HANDLER(WM_ERASEBKGND, OnEraseBkgnd) CHAIN_MSG_MAP(ATL::CComControl) END_MSG_MAP() @@ -149,6 +151,7 @@ class ATL_NO_VTABLE CMapWinGISControl : LRESULT OnSize(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnGdalDispatch(UINT, WPARAM, LPARAM, BOOL& handled); LRESULT OnDestroy(UINT, WPARAM, LPARAM, BOOL& handled); + LRESULT OnEraseBkgnd(UINT, WPARAM, LPARAM, BOOL& handled); static void DispatchGdalNotifications(void* control) noexcept; HRESULT OnDraw(ATL_DRAWINFO& drawInfo); ~CMapWinGISControl(); @@ -166,6 +169,7 @@ class ATL_NO_VTABLE CMapWinGISControl : mwg::MapView _view; LONGLONG _featureCount = 0; + mwg::activex::FrameBuffer _frameBuffer; mwg::activex::VectorLayerRenderer _renderer; mwg::activex::GdalEventSource _gdalEvents; POINT _lastPanPoint{}; diff --git a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp index 4a19365..1ae91f1 100644 --- a/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp +++ b/src/MapWinGIS.ActiveX/OpenGlVectorLayerRenderer.cpp @@ -564,7 +564,8 @@ namespace mwg::activex if (!IsSupported(scene)) { _lastFailureReason = - L"The scene uses geometry that the OpenGL backend does not support."; + L"The OpenGL preview backend supports only convex, hole-free " + L"polygons; this frame was rendered with GDI."; return {S_FALSE, std::nullopt}; } diff --git a/tests/MapWinGIS.ActiveX.Tests/FrameBufferTests.cpp b/tests/MapWinGIS.ActiveX.Tests/FrameBufferTests.cpp new file mode 100644 index 0000000..c62c4a0 --- /dev/null +++ b/tests/MapWinGIS.ActiveX.Tests/FrameBufferTests.cpp @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: MPL-2.0 + +/** + * @file FrameBufferTests.cpp + * @brief Tests the offscreen frame buffer used for flicker-free painting. + * + * Covers allocation, resizing, reuse for equal sizes, and pixel round-trips + * through the backed device context. + */ +#include + +#include + +#include "FrameBuffer.h" + +TEST(FrameBuffer, IsInvalidBeforeResize) +{ + mwg::activex::FrameBuffer buffer; + EXPECT_FALSE(buffer.IsValid()); + EXPECT_EQ(buffer.Get(), nullptr); + EXPECT_EQ(buffer.Width(), 0); + EXPECT_EQ(buffer.Height(), 0); +} + +TEST(FrameBuffer, RejectsNonPositiveSizes) +{ + mwg::activex::FrameBuffer buffer; + EXPECT_FALSE(buffer.Resize(0, 10)); + EXPECT_FALSE(buffer.Resize(10, 0)); + EXPECT_FALSE(buffer.Resize(-1, 10)); + EXPECT_FALSE(buffer.Resize(10, -1)); + EXPECT_FALSE(buffer.IsValid()); +} + +TEST(FrameBuffer, AllocatesRequestedSize) +{ + mwg::activex::FrameBuffer buffer; + ASSERT_TRUE(buffer.Resize(120, 80)); + EXPECT_TRUE(buffer.IsValid()); + EXPECT_NE(buffer.Get(), nullptr); + EXPECT_EQ(buffer.Width(), 120); + EXPECT_EQ(buffer.Height(), 80); +} + +TEST(FrameBuffer, KeepsBufferForSameSize) +{ + mwg::activex::FrameBuffer buffer; + ASSERT_TRUE(buffer.Resize(64, 48)); + const HDC deviceContext = buffer.Get(); + + ASSERT_TRUE(buffer.Resize(64, 48)); + EXPECT_EQ(buffer.Get(), deviceContext); + EXPECT_EQ(buffer.Width(), 64); + EXPECT_EQ(buffer.Height(), 48); +} + +TEST(FrameBuffer, ResizesToNewDimensions) +{ + mwg::activex::FrameBuffer buffer; + ASSERT_TRUE(buffer.Resize(64, 48)); + ASSERT_TRUE(buffer.Resize(100, 60)); + EXPECT_TRUE(buffer.IsValid()); + EXPECT_EQ(buffer.Width(), 100); + EXPECT_EQ(buffer.Height(), 60); +} + +TEST(FrameBuffer, FailedResizeKeepsPreviousBuffer) +{ + mwg::activex::FrameBuffer buffer; + ASSERT_TRUE(buffer.Resize(64, 48)); + const HDC deviceContext = buffer.Get(); + + EXPECT_FALSE(buffer.Resize(0, 100)); + EXPECT_TRUE(buffer.IsValid()); + EXPECT_EQ(buffer.Get(), deviceContext); + EXPECT_EQ(buffer.Width(), 64); + EXPECT_EQ(buffer.Height(), 48); +} + +TEST(FrameBuffer, DrawnContentRoundTripsThroughTheBitmap) +{ + mwg::activex::FrameBuffer buffer; + ASSERT_TRUE(buffer.Resize(32, 32)); + + const RECT fill{0, 0, 32, 32}; + ASSERT_NE( + ::FillRect(buffer.Get(), &fill, static_cast(GetStockObject(BLACK_BRUSH))), + 0); + EXPECT_EQ(::GetPixel(buffer.Get(), 10, 10), RGB(0, 0, 0)); + + // A resize replaces the bitmap, so the new buffer starts uninitialized. + ASSERT_TRUE(buffer.Resize(40, 40)); + ASSERT_NE( + ::FillRect(buffer.Get(), &fill, static_cast(GetStockObject(WHITE_BRUSH))), + 0); + EXPECT_EQ(::GetPixel(buffer.Get(), 5, 5), RGB(255, 255, 255)); +} + +TEST(FrameBuffer, ReleaseResetsStateAndAllowsReuse) +{ + mwg::activex::FrameBuffer buffer; + ASSERT_TRUE(buffer.Resize(10, 10)); + + buffer.Release(); + EXPECT_FALSE(buffer.IsValid()); + EXPECT_EQ(buffer.Get(), nullptr); + EXPECT_EQ(buffer.Width(), 0); + EXPECT_EQ(buffer.Height(), 0); + + ASSERT_TRUE(buffer.Resize(10, 10)); + EXPECT_TRUE(buffer.IsValid()); +} diff --git a/tests/MapWinGIS.ActiveX.Tests/MapWinGIS.ActiveX.Tests.vcxproj b/tests/MapWinGIS.ActiveX.Tests/MapWinGIS.ActiveX.Tests.vcxproj index cf2ecc3..c29568e 100644 --- a/tests/MapWinGIS.ActiveX.Tests/MapWinGIS.ActiveX.Tests.vcxproj +++ b/tests/MapWinGIS.ActiveX.Tests/MapWinGIS.ActiveX.Tests.vcxproj @@ -56,6 +56,7 @@ + diff --git a/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp b/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp index f9ceecc..27d3b1f 100644 --- a/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp +++ b/tests/MapWinGIS.ActiveX.Tests/VectorLayerRendererTests.cpp @@ -289,7 +289,8 @@ TEST(VectorLayerRenderer, OpenGlRejectsTheFrameWhenAnyLayerIsUnsupported) S_FALSE); EXPECT_EQ( renderer.GetLastFailureReason(), - L"The scene uses geometry that the OpenGL backend does not support."); + L"The OpenGL preview backend supports only convex, hole-free " + L"polygons; this frame was rendered with GDI."); } TEST(VectorLayerRenderer, FailsWithoutDrawingNonFiniteCoordinates) @@ -359,7 +360,8 @@ TEST(VectorLayerRenderer, UsesGdiForPolygonWithInteriorRing) mwg::activex::VectorRenderBackendKind::Gdi); EXPECT_EQ( renderer.GetDiagnostic(), - L"The scene uses geometry that the OpenGL backend does not support."); + L"The OpenGL preview backend supports only convex, hole-free " + L"polygons; this frame was rendered with GDI."); EXPECT_EQ(GetPixel(surface.Get(), 30, 50), kFill); EXPECT_EQ(GetPixel(surface.Get(), 60, 50), GetSysColor(COLOR_WINDOW)); }