wasm: validate kicad hi-dpi scaling

This commit is contained in:
Viktor Vaczi 2026-03-22 12:48:05 +01:00
commit a49ed49d5d
12 changed files with 490 additions and 30 deletions

View file

@ -146,7 +146,8 @@ all: minimal_test.html \
$(S)/maximize/maximize_test.html \
$(S)/earlysize/earlysize_test.html \
$(S)/threadpool/threadpool_test.html \
$(S)/logerror/logerror_test.html
$(S)/logerror/logerror_test.html \
$(S)/retinascale/retinascale_test.html
# Main test app (uses GL)
minimal_test.o: minimal_test.cpp
@ -493,8 +494,17 @@ earlysize: $(S)/earlysize/earlysize_test.html
threadpool: $(S)/threadpool/threadpool_test.html
logerror: $(S)/logerror/logerror_test.html
# Retina/HiDPI scaling test
$(S)/retinascale/retinascale_test.o: $(S)/retinascale/retinascale_test.cpp
$(CXX) -c $(CXXFLAGS) $< -o $@
$(S)/retinascale/retinascale_test.html: $(S)/retinascale/retinascale_test.o $(WX_CORE_LIB)
$(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
retinascale: $(S)/retinascale/retinascale_test.html
clean:
rm -f minimal_test.o minimal_test.html minimal_test.js minimal_test.wasm
rm -f $(S)/*/*.o $(S)/*/*.html $(S)/*/*.js $(S)/*/*.wasm
.PHONY: all clean menu clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize threadpool logerror
.PHONY: all clean menu clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize threadpool logerror retinascale

View file

@ -79,11 +79,10 @@
var canvas = document.createElement('canvas');
canvas.id = 'canvas';
canvas.style.display = 'none';
// Set both CSS display size and internal pixel resolution
// wx.js owns the backing-store size via setWindowRect(); keep the HTML
// shell responsible only for the CSS size.
var width = window.innerWidth;
var height = window.innerHeight;
canvas.width = width;
canvas.height = height;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
canvas.oncontextmenu = function() { event.preventDefault(); };

View file

@ -0,0 +1,109 @@
// Retina/HiDPI Scaling Test
// Verifies that bitmaps render at correct logical pixel sizes regardless of
// display scale factor. Icons should appear the same size on 1x and 2x displays.
#include "wx/wx.h"
#include "wx/dcmemory.h"
// Create a colored icon bitmap at the given size with scaleFactor=1.0
// (matching how KiCad creates toolbar icons via KiScaledBitmap)
wxBitmap CreateTestIcon(const wxColour& color, int size)
{
wxBitmap bmp(size, size);
wxMemoryDC dc(bmp);
dc.SetBackground(wxBrush(wxColour(240, 240, 240)));
dc.Clear();
dc.SetPen(wxPen(*wxBLACK, 1));
dc.SetBrush(wxBrush(color));
dc.DrawRectangle(1, 1, size - 2, size - 2);
// Draw a diagonal cross to make scaling visible
dc.SetPen(wxPen(color, 2));
dc.DrawLine(2, 2, size - 2, size - 2);
dc.DrawLine(size - 2, 2, 2, size - 2);
return bmp;
}
class RetinaScaleFrame : public wxFrame
{
public:
RetinaScaleFrame() : wxFrame(nullptr, wxID_ANY, "Retina Scale Test",
wxDefaultPosition, wxSize(800, 600))
{
// Log scale factor info
double contentSF = GetContentScaleFactor();
double dpiSF = GetDPIScaleFactor();
wxLogMessage("ContentScaleFactor: %.1f, DPIScaleFactor: %.1f", contentSF, dpiSF);
wxPanel* panel = new wxPanel(this);
wxBoxSizer* mainSizer = new wxBoxSizer(wxVERTICAL);
// Create a toolbar with icons at different sizes
wxToolBar* toolbar = CreateToolBar();
toolbar->SetName("toolbar");
wxBitmap icon16 = CreateTestIcon(*wxRED, 16);
wxBitmap icon24 = CreateTestIcon(*wxBLUE, 24);
wxBitmap icon32 = CreateTestIcon(*wxGREEN, 32);
toolbar->AddTool(1001, "Icon16", icon16, "16px icon");
toolbar->AddTool(1002, "Icon24", icon24, "24px icon");
toolbar->AddTool(1003, "Icon32", icon32, "32px icon");
toolbar->Realize();
// Info label
wxString info = wxString::Format(
"Scale: content=%.1f dpi=%.1f\n"
"Icons should be 16, 24, 32 logical pixels wide respectively.\n"
"Below: bitmap buttons with the same icons for comparison.",
contentSF, dpiSF);
wxStaticText* label = new wxStaticText(panel, wxID_ANY, info);
label->SetName("info");
mainSizer->Add(label, 0, wxALL, 10);
// Bitmap buttons row for comparison
wxBoxSizer* btnSizer = new wxBoxSizer(wxHORIZONTAL);
wxBitmapButton* btn16 = new wxBitmapButton(panel, wxID_ANY, icon16);
btn16->SetName("btn16");
btnSizer->Add(btn16, 0, wxALL, 5);
wxBitmapButton* btn24 = new wxBitmapButton(panel, wxID_ANY, icon24);
btn24->SetName("btn24");
btnSizer->Add(btn24, 0, wxALL, 5);
wxBitmapButton* btn32 = new wxBitmapButton(panel, wxID_ANY, icon32);
btn32->SetName("btn32");
btnSizer->Add(btn32, 0, wxALL, 5);
mainSizer->Add(btnSizer, 0, wxLEFT, 5);
// Reference line: a static text showing expected sizes
wxStaticText* refLabel = new wxStaticText(panel, wxID_ANY,
"If scaling is correct, the red icon above should be ~16px, "
"blue ~24px, green ~32px (logical pixels).");
refLabel->SetName("reference");
mainSizer->Add(refLabel, 0, wxALL, 10);
panel->SetSizer(mainSizer);
wxLogMessage("RetinaScaleFrame created successfully");
}
};
class RetinaScaleApp : public wxApp
{
public:
bool OnInit() override
{
RetinaScaleFrame* frame = new RetinaScaleFrame();
frame->Show(true);
return true;
}
};
wxIMPLEMENT_APP(RetinaScaleApp);

View file

@ -0,0 +1,16 @@
// Retina/HiDPI Scaling Test - Verifies bitmaps render at correct logical pixel sizes
import { test, expect, tryLoadApp } from './utils/fixtures';
test.describe('Retina Scale Tests', () => {
test('Bitmap icons render at correct logical size', async ({ page, testLogger }) => {
await page.goto('/standalone/retinascale/retinascale_test.html');
const loaded = await tryLoadApp(page);
await page.screenshot({ path: 'test-results/retinascale-01-loaded.png', fullPage: true });
expect(loaded, 'Retina scale test app should load').toBe(true);
expect(testLogger.errors.filter(e => !e.includes('favicon'))).toHaveLength(0);
});
});

View file

@ -1,3 +1,6 @@
import * as fs from 'fs';
import * as path from 'path';
import type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
import { clickByLabel } from '../e2e/utils/element-tracker';
@ -5,44 +8,352 @@ import { clickByLabel } from '../e2e/utils/element-tracker';
* PCBnew WASM E2E Tests
*/
const PCBNEW_REFERENCE = path.resolve(__dirname, '../wizard-04-finish-headless.png');
const REFERENCE_REGIONS = [
{ name: 'header', x: 0, y: 0, width: 1280, height: 90, maxDiffRatio: 0.12, maxMeanChannelDiff: 12 },
] as const;
type CanvasMetrics = {
dpr: number;
mainCanvas: null | {
width: number;
height: number;
rectWidth: number;
rectHeight: number;
};
glCanvas: null | {
id: string;
width: number;
height: number;
rectWidth: number;
rectHeight: number;
viewport: number[] | null;
};
};
type RegistryMetrics = {
elementStats: null | {
total: number;
byType: Record<string, number>;
};
renderedStats: null | {
total: number;
byType: Record<string, number>;
};
toolbars: Array<{
id: string;
typeName: string;
screenX: number;
screenY: number;
width: number;
height: number;
label: string;
name: string;
}>;
auiParts: Array<{
id: string;
subType: string;
label: string;
screenX: number;
screenY: number;
width: number;
height: number;
}>;
};
type ReferenceComparison = {
name: string;
actualWidth: number;
actualHeight: number;
referenceWidth: number;
referenceHeight: number;
diffPixels: number;
diffRatio: number;
meanChannelDiff: number;
};
type ReferenceRegion = typeof REFERENCE_REGIONS[number];
async function compareToReference(
page: Page,
actualPng: Buffer,
referencePath: string,
region: ReferenceRegion
): Promise<ReferenceComparison> {
const referencePng = fs.readFileSync(referencePath);
return page.evaluate(async ({ actualBase64, referenceBase64, crop }) => {
const loadImage = async (base64: string): Promise<HTMLImageElement> => {
const image = new Image();
image.src = `data:image/png;base64,${base64}`;
await image.decode();
return image;
};
const [actual, reference] = await Promise.all([
loadImage(actualBase64),
loadImage(referenceBase64),
]);
if (actual.width !== reference.width || actual.height !== reference.height) {
return {
name: crop.name,
actualWidth: actual.width,
actualHeight: actual.height,
referenceWidth: reference.width,
referenceHeight: reference.height,
diffPixels: Number.POSITIVE_INFINITY,
diffRatio: Number.POSITIVE_INFINITY,
meanChannelDiff: Number.POSITIVE_INFINITY,
};
}
const canvas = document.createElement('canvas');
canvas.width = crop.width;
canvas.height = crop.height;
const context = canvas.getContext('2d', { willReadFrequently: true });
if (!context) {
throw new Error('2D canvas context unavailable for screenshot comparison');
}
context.drawImage(actual, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height);
const actualData = context.getImageData(0, 0, canvas.width, canvas.height).data;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(reference, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height);
const referenceData = context.getImageData(0, 0, canvas.width, canvas.height).data;
let diffPixels = 0;
let totalChannelDiff = 0;
for (let i = 0; i < actualData.length; i += 4) {
const dr = Math.abs(actualData[i] - referenceData[i]);
const dg = Math.abs(actualData[i + 1] - referenceData[i + 1]);
const db = Math.abs(actualData[i + 2] - referenceData[i + 2]);
const da = Math.abs(actualData[i + 3] - referenceData[i + 3]);
const maxDiff = Math.max(dr, dg, db, da);
totalChannelDiff += dr + dg + db + da;
if (maxDiff > 16) {
diffPixels += 1;
}
}
return {
name: crop.name,
actualWidth: actual.width,
actualHeight: actual.height,
referenceWidth: reference.width,
referenceHeight: reference.height,
diffPixels,
diffRatio: diffPixels / (canvas.width * canvas.height),
meanChannelDiff: totalChannelDiff / actualData.length,
};
}, {
actualBase64: actualPng.toString('base64'),
referenceBase64: referencePng.toString('base64'),
crop: region,
});
}
async function getCanvasMetrics(page: Page): Promise<CanvasMetrics> {
return page.evaluate(() => {
const dpr = window.devicePixelRatio || 1;
const mainCanvas = document.querySelector('#canvas') as HTMLCanvasElement | null;
const glCanvas =
Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
.map((canvas) => canvas as HTMLCanvasElement)
.find((canvas) => {
const rect = canvas.getBoundingClientRect();
const style = window.getComputedStyle(canvas);
return style.display !== 'none' && rect.width > 0 && rect.height > 0;
}) ??
document.querySelector('[id^="glcanvas-"]') as HTMLCanvasElement | null;
const mainRect = mainCanvas?.getBoundingClientRect();
const glRect = glCanvas?.getBoundingClientRect();
const gl =
glCanvas?.getContext('webgl2') ||
glCanvas?.getContext('webgl');
const viewport = gl ? Array.from(gl.getParameter(gl.VIEWPORT) as Int32Array | number[]) : null;
return {
dpr,
mainCanvas: mainCanvas && mainRect ? {
width: mainCanvas.width,
height: mainCanvas.height,
rectWidth: mainRect.width,
rectHeight: mainRect.height,
} : null,
glCanvas: glCanvas && glRect ? {
id: glCanvas.id,
width: glCanvas.width,
height: glCanvas.height,
rectWidth: glRect.width,
rectHeight: glRect.height,
viewport,
} : null,
};
});
}
async function getRegistryMetrics(page: Page): Promise<RegistryMetrics> {
return page.evaluate(() => {
const registry = window.wxElementRegistry;
if (!registry) {
return {
elementStats: null,
renderedStats: null,
toolbars: [],
auiParts: [],
};
}
const allElements = registry.findAll({ visible: true });
const toolbars = allElements
.filter((element) => /ToolBar/.test(element.typeName))
.map((element) => ({
id: element.id,
typeName: element.typeName,
screenX: element.screenX,
screenY: element.screenY,
width: element.width,
height: element.height,
label: element.label,
name: element.name,
}));
const auiParts = registry.findAllRendered
? registry.findAllRendered({ elementType: 'auipart' })
.map((part) => ({
id: part.id,
subType: part.subType,
label: part.label,
screenX: part.screenX,
screenY: part.screenY,
width: part.width,
height: part.height,
}))
: [];
return {
elementStats: registry.getStats(),
renderedStats: registry.getRenderedStats ? registry.getRenderedStats() : null,
toolbars,
auiParts,
};
});
}
test.describe('PCBnew WASM', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/kicad/pcbnew.html');
});
test('click through setup wizard to load PCBnew', async ({ page, testLogger }) => {
// Wait for main canvas to be visible (KiCad takes time to initialize)
test('click through setup wizard to load PCBnew', async ({ page }) => {
await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
await page.waitForTimeout(2000);
// Screenshot initial wizard state
await page.screenshot({ path: 'test-results/wizard-00-initial.png' });
await page.screenshot({ path: 'test-results/wizard-00-initial.png', scale: 'device' });
// Click through wizard - try Next >, then Finish if not found
for (let i = 1; i <= 10; i++) {
let clicked = await clickByLabel(page, 'Next >');
if (clicked) {
// Continue to next page
} else {
// Try Finish button
if (!clicked) {
clicked = await clickByLabel(page, 'Finish');
if (clicked) {
await page.waitForTimeout(500);
await page.screenshot({ path: `test-results/wizard-${String(i).padStart(2, '0')}-finish.png` });
break;
} else {
break;
await page.screenshot({
path: `test-results/wizard-${String(i).padStart(2, '0')}-finish.png`,
scale: 'device'
});
}
break;
}
await page.waitForTimeout(500);
await page.screenshot({ path: `test-results/wizard-${String(i).padStart(2, '0')}.png` });
await page.screenshot({
path: `test-results/wizard-${String(i).padStart(2, '0')}.png`,
scale: 'device'
});
}
// Wait for PCBnew to fully load
await page.waitForTimeout(2000);
await page.screenshot({ path: 'test-results/pcbnew-loaded.png' });
const metrics = await getCanvasMetrics(page);
const registryMetrics = await getRegistryMetrics(page);
expect(metrics.dpr).toBeGreaterThan(1);
expect(metrics.mainCanvas).not.toBeNull();
expect(metrics.glCanvas).not.toBeNull();
expect(registryMetrics.toolbars.length).toBeGreaterThanOrEqual(4);
if (!metrics.mainCanvas || !metrics.glCanvas) {
throw new Error('KiCad canvases not initialized');
}
expect(Math.round(metrics.mainCanvas.rectWidth * metrics.dpr)).toBe(metrics.mainCanvas.width);
expect(Math.round(metrics.mainCanvas.rectHeight * metrics.dpr)).toBe(metrics.mainCanvas.height);
expect(metrics.glCanvas.rectWidth).toBeGreaterThan(800);
expect(metrics.glCanvas.rectHeight).toBeGreaterThan(500);
expect(Math.round(metrics.glCanvas.rectWidth * metrics.dpr)).toBe(metrics.glCanvas.width);
expect(Math.round(metrics.glCanvas.rectHeight * metrics.dpr)).toBe(metrics.glCanvas.height);
const viewport = metrics.glCanvas.viewport;
expect(viewport).not.toBeNull();
if (!viewport) {
throw new Error('WebGL viewport unavailable');
}
expect(viewport[2]).toBe(metrics.glCanvas.width);
expect(viewport[3]).toBe(metrics.glCanvas.height);
const verticalToolbars = registryMetrics.toolbars.filter((toolbar) => toolbar.height > 100);
expect(verticalToolbars).toHaveLength(2);
for (const toolbar of verticalToolbars) {
expect(toolbar.width).toBeLessThanOrEqual(40);
}
const appearancePane = registryMetrics.auiParts.find((part) =>
part.subType === 'content' && part.label === 'Appearance'
);
expect(appearancePane).toBeTruthy();
if (!appearancePane) {
throw new Error('Appearance pane metrics unavailable');
}
expect(appearancePane.width).toBeGreaterThanOrEqual(200);
expect(appearancePane.width).toBeLessThanOrEqual(240);
await page.evaluate(() => {
document.documentElement.style.cursor = 'none';
document.body.style.cursor = 'none';
});
const cssScreenshot = await page.screenshot({
path: 'test-results/pcbnew-loaded-css.png',
scale: 'css'
});
for (const region of REFERENCE_REGIONS) {
const reference = await compareToReference(page, cssScreenshot, PCBNEW_REFERENCE, region);
expect(reference.actualWidth).toBe(reference.referenceWidth);
expect(reference.actualHeight).toBe(reference.referenceHeight);
expect(reference.diffRatio, `${reference.name} diff ratio`).toBeLessThan(region.maxDiffRatio);
expect(reference.meanChannelDiff, `${reference.name} mean channel diff`).toBeLessThan(region.maxMeanChannelDiff);
}
await page.screenshot({ path: 'test-results/pcbnew-loaded.png', scale: 'device' });
// Verify PCBnew loaded
const canvasCount = await page.locator('canvas').count();
expect(canvasCount).toBeGreaterThan(0);
});

View file

@ -9,7 +9,7 @@
"build-wasm": "cd apps && make -f Makefile.wasm",
"serve": "npx serve apps -p 8080 -c ../serve.json",
"setup:kicad": "./scripts/setup-kicad-wasm.sh",
"test:kicad": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox",
"test:kicad": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox --headed",
"test:kicad:headed": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=chromium --headed"
},
"devDependencies": {

View file

@ -43,7 +43,16 @@ fi
# wxWidgets WASM JavaScript glue code (defines JS functions called from WASM)
echo "Copying wxWidgets WASM glue code..."
cp "$PROJECT_ROOT/wxwidgets/build/wasm/wx.js" "$KICAD_TEST/"
if [ -f "$OUTPUT_DIR/wx.js" ]; then
cp "$OUTPUT_DIR/wx.js" "$KICAD_TEST/"
else
if docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
kicad-wasm-builder:/workspace/build-wasm/wxwidgets/build/wasm/wx.js "$KICAD_TEST/" 2>/dev/null; then
:
else
cp "$PROJECT_ROOT/wxwidgets/build/wasm/wx.js" "$KICAD_TEST/"
fi
fi
echo "KiCad WASM files copied to $KICAD_TEST"
ls -lh "$KICAD_TEST"

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB