From 1b1e7884eeb38be2a7e1d72148826979e48e9d07 Mon Sep 17 00:00:00 2001 From: Istvan Matejcsok <119620946+matejcsok-ee@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:00:06 +0200 Subject: [PATCH] =?UTF-8?q?test:=20=F0=9F=92=8D=20wxWidgets=20secondary=20?= =?UTF-8?q?canvas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/WHATWORKS.md | 1 + tests/apps/Makefile.wasm | 20 +++ .../secondary-glcanvas_test.cpp | 99 +++++++++++ tests/e2e/secondary-glcanvas.spec.ts | 96 +++++++++++ tests/kicad/3d-viewer.spec.ts | 156 +++++++++++++++--- 5 files changed, 345 insertions(+), 27 deletions(-) create mode 100644 tests/apps/standalone/secondary-glcanvas/secondary-glcanvas_test.cpp create mode 100644 tests/e2e/secondary-glcanvas.spec.ts diff --git a/tests/WHATWORKS.md b/tests/WHATWORKS.md index c1f9770..e730664 100644 --- a/tests/WHATWORKS.md +++ b/tests/WHATWORKS.md @@ -212,6 +212,7 @@ Organized in `apps/standalone/` folders: | textdecor/textdecor_test | WORKS | 1/1 | Text underline/strikethrough decorations | | bitmask/bitmask_test | WORKS | 1/1 | Bitmap masking with wxMask transparency | | regions/regions_test | WORKS | 1/1 | Non-rectangular region clipping | +| secondary-glcanvas/secondary-glcanvas_test | WORKS | 1/1 | Secondary-window wxGLCanvas is z-lifted above the window chrome (3D-viewer occlusion regression) | --- diff --git a/tests/apps/Makefile.wasm b/tests/apps/Makefile.wasm index 724409b..1cbc3f9 100644 --- a/tests/apps/Makefile.wasm +++ b/tests/apps/Makefile.wasm @@ -834,3 +834,23 @@ $(S)/coroutine-pthread/vcall_repro.html: $(S)/coroutine-pthread/vcall_repro.o $( coroutine-pthread-vcall: $(S)/coroutine-pthread/vcall_repro.html .PHONY: coroutine-pthread-vcall + +# --- secondary-window wxGLCanvas compositing test (GL-enabled) ----------------- +# First GL-linked standalone app. Reproduces the "two canvases over each other" +# bug: a wxGLCanvas in a secondary top-level frame must be z-lifted above that +# frame's own window chrome (wx.js createGLCanvas fix). GL link recipe mirrors +# tests/gal-regression/wasm/Makefile (wx 'gl' lib + -sMAX_WEBGL_VERSION=2). +WX_LDFLAGS_GL := $(shell $(WXCONFIG) --libs base,core,gl) +EM_GL_FLAGS := -sMAX_WEBGL_VERSION=2 +LDFLAGS_GL = $(DEBUG_LDFLAGS) $(BASE_LDFLAGS) $(EM_GL_FLAGS) $(WX_LDFLAGS_GL) + +$(S)/secondary-glcanvas/secondary-glcanvas_test.o: $(S)/secondary-glcanvas/secondary-glcanvas_test.cpp + $(CXX) -c $(CXXFLAGS) $< -o $@ + +$(S)/secondary-glcanvas/secondary-glcanvas_test.html: $(S)/secondary-glcanvas/secondary-glcanvas_test.o $(WX_CORE_LIB) $(JS_FILES) + $(CXX) $< $(LDFLAGS_GL) --pre-js $(JS) --shell-file $(HTML) -o $@ + +secondary-glcanvas: $(S)/secondary-glcanvas/secondary-glcanvas_test.html + +# Include the new app in the default `all` build (prerequisites accumulate). +all: $(S)/secondary-glcanvas/secondary-glcanvas_test.html diff --git a/tests/apps/standalone/secondary-glcanvas/secondary-glcanvas_test.cpp b/tests/apps/standalone/secondary-glcanvas/secondary-glcanvas_test.cpp new file mode 100644 index 0000000..f5d5fe8 --- /dev/null +++ b/tests/apps/standalone/secondary-glcanvas/secondary-glcanvas_test.cpp @@ -0,0 +1,99 @@ +// Secondary-window wxGLCanvas compositing test (WASM DOM port). +// +// Regression for the "two canvases over each other" bug. The wx DOM port draws +// each top-level window's chrome onto 2D canvases and reveals a wxGLCanvas +// through it. A wxGLCanvas in a *secondary* top-level window was hidden behind +// that window's own opaque chrome: every glcanvas-* was hard-coded to z-index +// 100, but showing the secondary frame raises its window-N chrome div to +// z-index 101 (raiseWindow → maxZ+1 over the visible main canvas at 100), so the +// chrome painted over the GL canvas. The fix (wx.js createGLCanvas) lifts a GL +// canvas created while another GL canvas is already visible to z-index +// 2147483647, above the chrome. +// +// This is the minimal pure-wxWidgets repro: a main frame with a wxGLCanvas plus +// a button that opens a SECOND top-level frame with its own wxGLCanvas. Opening +// the second frame only after the main one is visible mirrors opening KiCad's 3D +// viewer from a menu — and is exactly what makes createGLCanvas's hasVisibleGL +// true and triggers raiseWindow on the secondary frame. The e2e spec asserts the +// secondary GL canvas is z-lifted above the other canvases and the window chrome. + +#include "wx/wxprec.h" + +#ifndef WX_PRECOMP + #include "wx/wx.h" +#endif + +#include "wx/glcanvas.h" + +#ifdef __EMSCRIPTEN__ +#include +#endif + +namespace +{ +// Build a wxGLCanvas with default attributes; the wasm port's +// ConvertWXAttrsToWebGL fills in WebGL2 + preserveDrawingBuffer. No GL drawing is +// needed — createGLCanvas (which assigns the z-index) runs at construction. +wxGLCanvas* MakeGLCanvas( wxWindow* parent ) +{ + wxGLAttributes attrs; + attrs.PlatformDefaults().Defaults().EndList(); + return new wxGLCanvas( parent, attrs, wxID_ANY, wxDefaultPosition, wxSize( 320, 240 ) ); +} +} // namespace + +// A secondary top-level frame that owns its own wxGLCanvas — the window whose +// chrome used to occlude its GL canvas. +class SecondGLFrame : public wxFrame +{ +public: + SecondGLFrame() + : wxFrame( nullptr, wxID_ANY, "Secondary GL Window", wxDefaultPosition, wxSize( 360, 280 ) ) + { + wxBoxSizer* sizer = new wxBoxSizer( wxVERTICAL ); + sizer->Add( MakeGLCanvas( this ), 1, wxEXPAND ); + SetSizer( sizer ); + } +}; + +class MainGLFrame : public wxFrame +{ +public: + MainGLFrame() + : wxFrame( nullptr, wxID_ANY, "Main GL Window", wxDefaultPosition, wxSize( 700, 520 ) ) + { + wxPanel* panel = new wxPanel( this ); + wxBoxSizer* sizer = new wxBoxSizer( wxVERTICAL ); + + wxButton* open = new wxButton( panel, wxID_ANY, "Open Second Window" ); + open->Bind( wxEVT_BUTTON, &MainGLFrame::OnOpenSecond, this ); + + sizer->Add( open, 0, wxALL, 8 ); + sizer->Add( MakeGLCanvas( panel ), 1, wxEXPAND ); + panel->SetSizer( sizer ); + } + +private: + void OnOpenSecond( wxCommandEvent& WXUNUSED( evt ) ) + { + // Create the secondary frame's GL canvas only now, with the main canvas + // already visible — the conditions the fix keys on. + ( new SecondGLFrame() )->Show( true ); + } +}; + +class SecondaryGLCanvasApp : public wxApp +{ +public: + bool OnInit() override + { + if( !wxApp::OnInit() ) + return false; + + MainGLFrame* frame = new MainGLFrame(); + frame->Show( true ); + return true; + } +}; + +wxIMPLEMENT_APP( SecondaryGLCanvasApp ); diff --git a/tests/e2e/secondary-glcanvas.spec.ts b/tests/e2e/secondary-glcanvas.spec.ts new file mode 100644 index 0000000..fea7a9e --- /dev/null +++ b/tests/e2e/secondary-glcanvas.spec.ts @@ -0,0 +1,96 @@ +import { test, expect, waitForApp } from './utils/fixtures'; +import { clickByLabel, waitForRegistry } from './utils/element-tracker'; + +/** + * Regression for the "two canvases over each other" bug — at the wxWidgets layer. + * + * The wasm DOM port draws each top-level window's chrome onto 2D canvases and + * reveals a wxGLCanvas through it. A wxGLCanvas in a SECONDARY top-level window + * was hidden behind that window's own opaque chrome: every `glcanvas-*` was + * hard-coded to z-index 100, but showing the secondary frame raises its + * `#window-N` chrome div to z-index 101 (`raiseWindow` → maxZ+1 over the visible + * main canvas at 100), so the chrome painted over the GL canvas. The fix + * (`wx.js` `createGLCanvas`) lifts a GL canvas created while another GL canvas is + * already visible to z-index 2147483647, above the chrome. + * + * This drives the minimal pure-wx repro app (a main frame with a wxGLCanvas + a + * button that opens a second top-level frame with its own wxGLCanvas) and asserts + * the stacking from computed styles — no pixels/screenshots, so it doesn't depend + * on actual GL rendering. Note `glcanvas-*`, `#window-N` and `.window-canvas` all + * have `pointer-events:none`, so `elementsFromPoint` can't see them; computed + * z-index is the right tool. + * + * Before the fix every `glcanvas-*` is z-index 100, so the secondary canvas is + * not strictly above the main one → FAILS. After the fix it is 2147483647 → PASSES. + */ +test.describe('secondary-window wxGLCanvas compositing', () => { + test('a wxGLCanvas in a secondary frame is z-lifted above the window chrome', async ({ page }) => { + await page.goto('/standalone/secondary-glcanvas/secondary-glcanvas_test.html'); + await waitForApp(page); + await waitForRegistry(page); + + // The main frame's GL canvas must be present and on-screen before we open + // the second window — that visible-canvas state is what the fix keys on. + await page.waitForFunction(() => { + const c = document.querySelector('#window-container canvas[id^="glcanvas-"]'); + return !!c && getComputedStyle(c).display !== 'none' + && (c as HTMLElement).getBoundingClientRect().width > 0; + }, undefined, { timeout: 30000 }); + + const before = await page.evaluate( + () => document.querySelectorAll('canvas[id^="glcanvas-"]').length); + + // Open the secondary frame (mirrors opening KiCad's 3D viewer). + expect(await clickByLabel(page, 'Open Second Window'), + 'the "Open Second Window" button should be clickable').toBe(true); + + // Wait for the secondary frame's NEW GL canvas to appear and be on-screen. + await page.waitForFunction((b: number) => { + const list = document.querySelectorAll('canvas[id^="glcanvas-"]'); + if (list.length <= b) return false; + const viewer = list[list.length - 1]; + return getComputedStyle(viewer).display !== 'none' + && viewer.getBoundingClientRect().width > 0; + }, before, { timeout: 30000 }); + + // Stacking order inside #window-container (a single z-index:1 stacking + // context). The secondary canvas is the newest glcanvas-*; it must + // out-stack every other GL canvas and every secondary window-N chrome div. + const stacking = await page.evaluate(() => { + const z = (el: Element) => parseInt(getComputedStyle(el).zIndex, 10) || 0; + const gls = Array.from(document.querySelectorAll('#window-container canvas[id^="glcanvas-"]')); + const windows = Array.from(document.querySelectorAll('#window-container [id^="window-"]')); + const viewer = gls[gls.length - 1]; + return { + glCount: gls.length, + viewerId: viewer ? viewer.id : null, + viewerZ: viewer ? z(viewer) : 0, + maxOtherGlZ: Math.max(0, ...gls.slice(0, -1).map(z)), + maxWindowZ: Math.max(0, ...windows.map(z)), + glZ: gls.map((c) => ({ id: c.id, z: z(c) })), + windowZ: windows.map((w) => ({ id: w.id, z: z(w) })), + }; + }); + console.log(`[TEST] stacking: ${JSON.stringify(stacking)}`); + + // Precondition: the secondary window actually opened (main + secondary canvas). + expect(stacking.glCount, + 'opening the second window should add a second WebGL canvas').toBeGreaterThanOrEqual(2); + + // THE regression assertion. Pre-fix every glcanvas-* shares z-index 100, so the + // secondary canvas is not strictly above the main one and the chrome occludes it. + expect(stacking.viewerZ, + `secondary GL canvas ${stacking.viewerId} (z=${stacking.viewerZ}) must stack strictly above ` + + `the other GL canvases (max z=${stacking.maxOtherGlZ}); an equal z-index means the window ` + + `chrome can occlude it. all=${JSON.stringify(stacking.glZ)}`) + .toBeGreaterThan(stacking.maxOtherGlZ); + + // The user-visible symptom: the GL canvas must paint at or above every secondary + // window's opaque 2D chrome. (>= not > to tolerate a window clamped to the same + // 2147483647 CSS z-index ceiling by raiseWindow.) + expect(stacking.viewerZ, + `secondary GL canvas (z=${stacking.viewerZ}) must not sit below any window chrome ` + + `(max window z=${stacking.maxWindowZ}). windows=${JSON.stringify(stacking.windowZ)}`) + .toBeGreaterThanOrEqual(stacking.maxWindowZ); + }); +}); diff --git a/tests/kicad/3d-viewer.spec.ts b/tests/kicad/3d-viewer.spec.ts index 0454340..1e125c9 100644 --- a/tests/kicad/3d-viewer.spec.ts +++ b/tests/kicad/3d-viewer.spec.ts @@ -74,6 +74,39 @@ function countGlCanvases(page: Page): Promise { return page.evaluate(() => document.querySelectorAll('canvas[id^="glcanvas-"]').length); } +// Open the 3D viewer (View → 3D Viewer, with an Alt+3 fallback) and wait for the +// secondary frame + its NEW `glcanvas-*` to appear. The main pcbnew board view is +// itself a wxGLCanvas, so the viewer is detected by the GL-canvas COUNT increasing. +// Returns the glcanvas count after opening. `glBefore` is the count beforehand. +async function openThreeDViewer(page: Page, glBefore: number): Promise { + let opened = false; + if (await clickMenuBarItem(page, 'View')) { + await page.waitForTimeout(400); + opened = await clickMenuItem(page, '3D Viewer'); + } + if (!opened) { + console.log('[TEST] View → 3D Viewer not found via menu; trying Alt+3'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + await page.keyboard.press('Alt+3'); + } + + await page.waitForFunction(() => { + // A new top-level window div beyond the main pcbnew frame. + return !!document.querySelector('#window-container [id^="window-"]') + || document.querySelectorAll('canvas[id^="glcanvas-"]').length > 0; + }, null, { timeout: 60000 }); + + await page.waitForFunction((before: number) => + document.querySelectorAll('canvas[id^="glcanvas-"]').length > before, + glBefore, { timeout: 60000 }); + + const glAfter = await countGlCanvases(page); + console.log(`[TEST] glcanvas count after opening 3D viewer: ${glAfter}`); + expect(glAfter, 'a new WebGL canvas should appear for the 3D viewer').toBeGreaterThan(glBefore); + return glAfter; +} + test.describe('3D viewer from pcbnew', () => { // One 187 MB wasm runtime is already heavy; keep this serial and generous. test.describe.configure({ mode: 'serial' }); @@ -89,33 +122,7 @@ test.describe('3D viewer from pcbnew', () => { const glBefore = await countGlCanvases(page); console.log(`[TEST] glcanvas count before opening 3D viewer: ${glBefore}`); - // ── Open the 3D viewer: View → 3D Viewer, with an Alt+3 fallback. ── - let opened = false; - if (await clickMenuBarItem(page, 'View')) { - await page.waitForTimeout(400); - opened = await clickMenuItem(page, '3D Viewer'); - } - if (!opened) { - console.log('[TEST] View → 3D Viewer not found via menu; trying Alt+3'); - await page.keyboard.press('Escape'); - await page.waitForTimeout(200); - await page.keyboard.press('Alt+3'); - } - - // ── Wait for the secondary frame + a NEW GL canvas to appear. ────── - await page.waitForFunction(() => { - // A new top-level window div beyond the main pcbnew frame. - return !!document.querySelector('#window-container [id^="window-"]') - || document.querySelectorAll('canvas[id^="glcanvas-"]').length > 0; - }, null, { timeout: 60000 }); - - await page.waitForFunction((before: number) => - document.querySelectorAll('canvas[id^="glcanvas-"]').length > before, - glBefore, { timeout: 60000 }); - - const glAfter = await countGlCanvases(page); - console.log(`[TEST] glcanvas count after opening 3D viewer: ${glAfter}`); - expect(glAfter, 'a new WebGL canvas should appear for the 3D viewer').toBeGreaterThan(glBefore); + await openThreeDViewer(page, glBefore); // The 3D reload + raytrace run through asyncify; give them time to build // the scene and render a few progressive passes. @@ -181,4 +188,99 @@ test.describe('3D viewer from pcbnew', () => { expect(stubbed, `3D viewer is still stubbed (build not enabled?):\n${stubbed.join('\n')}`).toEqual([]); }); + + /** + * Regression for the "two canvases over each other" bug. + * + * The wasm DOM port draws each window's chrome onto 2D canvases and reveals a + * wxGLCanvas through it. The CPU raytracer rendered the board correctly into the + * 3D viewer's own `glcanvas-*`, but on screen the user saw grey: the 3D viewer is + * a SECONDARY top-level frame, and when it is shown wx.js `raiseWindow()` lifts its + * opaque `#window-N` chrome div to z-index 101 (= the main GAL canvas's 100, + 1). + * Every `glcanvas-*` was hard-coded to z-index 100, so the frame's own window + * background painted OVER the GL canvas behind it — two canvases stacked, the + * opaque one on top. (The main editor escapes this because its window keeps + * `#canvas` transparent over the GAL region; a secondary frame's region is opaque.) + * + * The fix (wx.js `createGLCanvas`): a GL canvas created while another GL canvas is + * already visible belongs to a secondary window → lift it to z-index 2147483647 so + * it stacks above the chrome. + * + * This asserts the stacking straight from the DOM/computed-style — no pixels or + * screenshots — so it is independent of the slow raytrace and CI-safe on swiftshader + * (where WebGL-canvas screenshots come back blank). Note `glcanvas-*`, `#window-N` + * and `.window-canvas` all have `pointer-events:none`, so `elementsFromPoint` can't + * see them; computed z-index is the right tool. + * + * Before the fix: every `glcanvas-*` is z-index 100, so the viewer canvas is not + * strictly above the main GAL canvas (100 > 100 is false) → FAILS. + * After the fix: the viewer canvas is 2147483647 → PASSES. + */ + test('reveals the 3D viewer GL canvas above the window chrome (regression: occluded board)', + async ({ page, testLogger }) => { + await page.goto('/kicad/pcbnew.html'); + await waitForPcbnew(page); + + await loadBoard(page, testLogger); + + const glBefore = await countGlCanvases(page); + await openThreeDViewer(page, glBefore); + + // The z-index is assigned synchronously when the canvas is created, but wait + // until the newest glcanvas is actually on-screen (setGLCanvasRect ran → + // display:block, non-zero box) so its secondary frame's window-N div has been + // raised by raiseWindow() and the DOM stacking has settled. + await page.waitForFunction((before: number) => { + const list = document.querySelectorAll('canvas[id^="glcanvas-"]'); + if (list.length <= before) return false; + const viewer = list[list.length - 1]; + return getComputedStyle(viewer).display !== 'none' + && viewer.getBoundingClientRect().width > 0; + }, glBefore, { timeout: 60000 }); + + // Stacking order inside #window-container (a single z-index:1 stacking context). + // The 3D viewer canvas is the newest glcanvas-*; it must out-stack every other + // GL canvas and every secondary window-N chrome div, or the opaque chrome occludes it. + const stacking = await page.evaluate(() => { + const z = (el: Element) => parseInt(getComputedStyle(el).zIndex, 10) || 0; + const gls = Array.from(document.querySelectorAll('#window-container canvas[id^="glcanvas-"]')); + const windows = Array.from(document.querySelectorAll('#window-container [id^="window-"]')); + const viewer = gls[gls.length - 1]; + return { + glCount: gls.length, + viewerId: viewer ? viewer.id : null, + viewerZ: viewer ? z(viewer) : 0, + maxOtherGlZ: Math.max(0, ...gls.slice(0, -1).map(z)), + maxWindowZ: Math.max(0, ...windows.map(z)), + glZ: gls.map((c) => ({ id: c.id, z: z(c) })), + windowZ: windows.map((w) => ({ id: w.id, z: z(w) })), + }; + }); + console.log(`[TEST] stacking: ${JSON.stringify(stacking)}`); + + // Precondition: the viewer actually opened (main GAL canvas + viewer canvas). + expect(stacking.glCount, + 'the 3D viewer should add a second WebGL canvas').toBeGreaterThanOrEqual(2); + + // THE regression assertion. Pre-fix every glcanvas-* shares z-index 100, so the + // viewer canvas is not strictly above the main GAL canvas and the chrome occludes it. + expect(stacking.viewerZ, + `3D viewer canvas ${stacking.viewerId} (z=${stacking.viewerZ}) must stack strictly above ` + + `the other GL canvases (max z=${stacking.maxOtherGlZ}); an equal z-index means the ` + + `window chrome can occlude it. all=${JSON.stringify(stacking.glZ)}`) + .toBeGreaterThan(stacking.maxOtherGlZ); + + // The user-visible symptom: the GL canvas must paint at or above every secondary + // window's opaque 2D chrome. (>= not > to tolerate a window clamped to the same + // 2147483647 CSS z-index ceiling by raiseWindow.) + expect(stacking.viewerZ, + `3D viewer canvas (z=${stacking.viewerZ}) must not sit below any window chrome ` + + `(max window z=${stacking.maxWindowZ}). windows=${JSON.stringify(stacking.windowZ)}`) + .toBeGreaterThanOrEqual(stacking.maxWindowZ); + + // Sanity: opening the viewer didn't blow up the runtime. + const aborts = [...testLogger.consoleLogs, ...testLogger.errors] + .filter((l) => l.includes('Aborted(')); + expect(aborts, `WASM aborted while opening the 3D viewer:\n${aborts.join('\n\n')}`).toEqual([]); + }); });