fix(view): use zoom-to-cursor in WASM canvas (browser can't warp pointer for center-on-zoom)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4951174487
commit
98155496ba
2 changed files with 154 additions and 1 deletions
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit 91948d1c78df6389f19438c44189933db64ce2b3
|
||||
Subproject commit ac7d733787000f7988a122559888bc8d76924315
|
||||
153
tests/kicad/zoom-cursor.spec.ts
Normal file
153
tests/kicad/zoom-cursor.spec.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import type { Page } from '@playwright/test';
|
||||
import { test, expect } from './fixtures';
|
||||
import { clickByLabel } from '../e2e/utils/element-tracker';
|
||||
|
||||
/**
|
||||
* Zoom-to-cursor regression test (REAL mouse events).
|
||||
*
|
||||
* Guards the "Google-Maps zoom" behaviour: the point under the cursor stays fixed
|
||||
* when zooming, including on the FIRST wheel after the cursor moves to a new point.
|
||||
*
|
||||
* Root cause this guards against: KiCad's center_on_zoom default enabled cursor
|
||||
* warping (WX_VIEW_CONTROLS::onWheel -> CenterOnCursor -> KIPLATFORM::UI::WarpPointer).
|
||||
* Browsers cannot move the pointer, so the view recentred and the canvas "jumped"
|
||||
* on the first wheel after each move. The WASM build now forces zoom-to-cursor
|
||||
* (m_warpCursor = false; see WX_VIEW_CONTROLS::LoadSettings / EDA_DRAW_PANEL_GAL).
|
||||
*
|
||||
* Assertion strategy (robust on uniform-grid canvases): move to an off-centre point,
|
||||
* zoom IN twice, then zoom OUT twice at the SAME point. Zoom-to-cursor scales about a
|
||||
* fixed point, so in+out is invertible and the view returns to baseline (diff ~0). A
|
||||
* center-on-zoom recenter (the original "jump") is NOT invertible and leaves the view
|
||||
* translated, so it would fail. Also writes before/after screenshots
|
||||
* (test-results/zoom-<app>-*.png) for visual review per the project convention.
|
||||
*
|
||||
* Defaults to pl_editor (fast to build, no setup wizard). Override with ZOOM_APP.
|
||||
*/
|
||||
|
||||
const APP = process.env.ZOOM_APP || 'pl_editor';
|
||||
|
||||
async function waitForEditor( page: Page ): Promise<void> {
|
||||
await expect( page.locator( '#canvas' ) ).toBeVisible( { timeout: 90000 } );
|
||||
await page.waitForFunction( () => !!window.wxElementRegistry, null, { timeout: 90000 } );
|
||||
await page.waitForTimeout( 2000 );
|
||||
|
||||
// Dismiss the first-run setup wizard if present (pcbnew); harmless otherwise
|
||||
// (pl_editor's seeded config skips it). A modal wizard blocks canvas zoom.
|
||||
for ( let i = 0; i < 10; i++ ) {
|
||||
const next = await clickByLabel( page, 'Next >' );
|
||||
if ( !next ) {
|
||||
await clickByLabel( page, 'Finish' );
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout( 400 );
|
||||
}
|
||||
await page.waitForTimeout( 1500 );
|
||||
}
|
||||
|
||||
async function getGlBox( page: Page ): Promise<{ x: number; y: number; width: number; height: number }> {
|
||||
const id = await page.evaluate( () => {
|
||||
const visible = Array.from( document.querySelectorAll( '[id^="glcanvas-"]' ) )
|
||||
.map( ( c ) => c as HTMLCanvasElement )
|
||||
.find( ( c ) => {
|
||||
const rect = c.getBoundingClientRect();
|
||||
return window.getComputedStyle( c ).display !== 'none' && rect.width > 0 && rect.height > 0;
|
||||
} );
|
||||
return ( visible ?? ( document.querySelector( '[id^="glcanvas-"]' ) as HTMLCanvasElement | null ) )?.id ?? null;
|
||||
} );
|
||||
if ( !id ) throw new Error( 'No visible GL canvas found' );
|
||||
const box = await page.locator( `#${id}` ).boundingBox();
|
||||
if ( !box ) throw new Error( 'GL canvas bounding box unavailable' );
|
||||
return box;
|
||||
}
|
||||
|
||||
async function placeMarker( page: Page, x: number, y: number, color: string ): Promise<void> {
|
||||
await page.evaluate( ( { x, y, color } ) => {
|
||||
const dot = document.createElement( 'div' );
|
||||
dot.className = 'zoom-marker';
|
||||
dot.style.cssText =
|
||||
`position:fixed;left:${x - 7}px;top:${y - 7}px;width:14px;height:14px;` +
|
||||
`border:2px solid ${color};border-radius:50%;z-index:99999;pointer-events:none;`;
|
||||
document.body.appendChild( dot );
|
||||
}, { x, y, color } );
|
||||
}
|
||||
async function clearMarkers( page: Page ): Promise<void> {
|
||||
await page.evaluate( () => document.querySelectorAll( '.zoom-marker' ).forEach( ( e ) => e.remove() ) );
|
||||
}
|
||||
|
||||
/** Fraction of pixels that differ (luma) between two PNGs inside the canvas region. */
|
||||
async function diffRatio( page: Page, a: Buffer, b: Buffer, box: { x: number; y: number; width: number; height: number } ): Promise<number> {
|
||||
return page.evaluate( async ( { aB64, bB64, box } ) => {
|
||||
const load = async ( s: string ) => { const i = new Image(); i.src = `data:image/png;base64,${s}`; await i.decode(); return i; };
|
||||
const [ ia, ib ] = await Promise.all( [ load( aB64 ), load( bB64 ) ] );
|
||||
const w = Math.min( ia.width, ib.width ), h = Math.min( ia.height, ib.height );
|
||||
const px = ( img: HTMLImageElement ) => { const c = document.createElement( 'canvas' ); c.width = w; c.height = h; const x = c.getContext( '2d' )!; x.drawImage( img, 0, 0 ); return x.getImageData( 0, 0, w, h ).data; };
|
||||
const da = px( ia ), db = px( ib );
|
||||
const x0 = Math.max( 0, Math.round( box.x ) ), x1 = Math.min( w, Math.round( box.x + box.width ) );
|
||||
const y0 = Math.max( 0, Math.round( box.y ) ), y1 = Math.min( h, Math.round( box.y + box.height ) );
|
||||
let diff = 0, total = 0;
|
||||
for ( let y = y0; y < y1; y++ ) for ( let x = x0; x < x1; x++ ) {
|
||||
const i = ( y * w + x ) * 4;
|
||||
const la = 0.299 * da[ i ] + 0.587 * da[ i + 1 ] + 0.114 * da[ i + 2 ];
|
||||
const lb = 0.299 * db[ i ] + 0.587 * db[ i + 1 ] + 0.114 * db[ i + 2 ];
|
||||
if ( Math.abs( la - lb ) > 24 ) diff++;
|
||||
total++;
|
||||
}
|
||||
return diff / total;
|
||||
}, { aB64: a.toString( 'base64' ), bB64: b.toString( 'base64' ), box } );
|
||||
}
|
||||
|
||||
test.describe( `${APP} zoom-to-cursor`, () => {
|
||||
test.beforeEach( async ( { page } ) => {
|
||||
await page.goto( `/kicad/${APP}.html` );
|
||||
} );
|
||||
|
||||
test( 'zoom in then out at the same off-centre point returns to baseline', async ( { page } ) => {
|
||||
await waitForEditor( page );
|
||||
const box = await getGlBox( page );
|
||||
|
||||
// An off-centre cursor point. The discriminator only works off-centre: at the
|
||||
// centre every zoom mode looks the same.
|
||||
const P = { x: Math.round( box.x + box.width * 0.32 ), y: Math.round( box.y + box.height * 0.34 ) };
|
||||
const shot = ( n: string ) => page.screenshot( { path: `test-results/zoom-${APP}-${n}.png`, scale: 'css' } );
|
||||
|
||||
await placeMarker( page, P.x, P.y, '#ff2020' );
|
||||
const base = await shot( '00-baseline' );
|
||||
|
||||
// Move to P, then zoom IN twice at P (first wheel after the move is the case
|
||||
// the user reported breaking).
|
||||
await page.mouse.move( P.x, P.y );
|
||||
await page.waitForTimeout( 300 );
|
||||
await page.mouse.wheel( 0, -120 );
|
||||
await page.waitForTimeout( 300 );
|
||||
await page.mouse.wheel( 0, -120 );
|
||||
await page.waitForTimeout( 400 );
|
||||
const zoomedIn = await shot( '01-zoomed-in-at-P' );
|
||||
|
||||
// Zoom OUT twice at the SAME point (no mouse move in between).
|
||||
await page.mouse.wheel( 0, 120 );
|
||||
await page.waitForTimeout( 300 );
|
||||
await page.mouse.wheel( 0, 120 );
|
||||
await page.waitForTimeout( 400 );
|
||||
const restored = await shot( '02-zoomed-out-back' );
|
||||
|
||||
await clearMarkers( page );
|
||||
|
||||
const dIn = await diffRatio( page, base, zoomedIn, box ); // zoom actually happened
|
||||
const dBack = await diffRatio( page, base, restored, box ); // returned to baseline?
|
||||
console.log( `[zoom] app=${APP} P=${JSON.stringify( P )} dIn=${dIn.toFixed( 3 )} dBack=${dBack.toFixed( 3 )}` );
|
||||
|
||||
// 1) A zoom visibly happened (not a no-op).
|
||||
expect( dIn, 'zoom-in at P should visibly change the view' ).toBeGreaterThan( 0.006 );
|
||||
|
||||
// 2) Zoom-to-cursor is scaling about a fixed point, so in+out at the SAME point
|
||||
// is invertible and the view returns to baseline. Center-on-zoom recenters
|
||||
// the view on the cursor (the original "jump"), which is NOT invertible and
|
||||
// leaves the view translated far from baseline. So dBack must be small AND
|
||||
// much smaller than the zoomed-in delta.
|
||||
expect( dBack, 'in+out at the same point must return to baseline (zoom-to-cursor, not recenter)' )
|
||||
.toBeLessThan( dIn / 3 );
|
||||
|
||||
// Visual reference: test-results/zoom-<app>-*.png. The red marker shows the
|
||||
// cursor; the content under it must stay put while zooming.
|
||||
} );
|
||||
} );
|
||||
Loading…
Reference in a new issue