Closing the viewer destroys its wxGLCanvas's WebGL context; reopening mints a
new one. The gl1 shim cached GL names (FFP program, stream/scratch VBOs) in
never-reset statics behind `if (!handle)` guards — in the new context every
draw died with INVALID_OPERATION and the viewer showed only the clear color
("Reload time 0.031 s" is benign: warm model caches make the rebuild fast).
contextSync() (gl1_state.cpp) now detects the context change in programSync()
— the one choke point every shim draw crosses, and a path the 2D GAL never
reaches (a first attempt checking in the glBindTexture wrap saw the GAL's
context and thrash-rebuilt the program 23x per run) — and drops the cached
names for lazy rebuild in the new context. Context identity is a monotonic id
stamped on Emscripten's per-context record: the numeric
EMSCRIPTEN_WEBGL_CONTEXT_HANDLE is recycled, so a destroy-then-create can
return the same number and a handle comparison detects nothing.
The lost-position half is a wxwidgets wasm fix (pointer bump: GetFromWindow
reports display 0; saved geometry used to carry display=(unsigned)-1, which
LoadWindowState treats as "display not found" and re-centres the frame).
TDD (red observed before each fix, green after):
- tests/kicad/3d-viewer-reopen.spec.ts (new, own worker like the deadlock
spec): load board, open viewer, render-gate, drag by the titlebar, close
via the x, reopen; asserts the board re-renders (was: 1 distinct colour for
90 s) and the window position is restored (was: re-centred to 0,0 after
closing at 40,90). Green run logs exactly one [gl1] context-change line.
- 3d-regression harness: recreateContext() destroys the context AND swaps in
a fresh canvas element (a browser canvas keeps its context for life, so
same-element recreation hands back the live old context and hides the bug);
the new 3d-webgl spec test renders redraw-mini-board-navigator before and
after recreation and requires pixel-identical output. Parity: 47/47, zero
drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
182 lines
7.8 KiB
TypeScript
182 lines
7.8 KiB
TypeScript
/**
|
|
* 3D Renderer WebGL Regression — capture-only.
|
|
*
|
|
* Renders every scenario of the 3D suite (tests/3d-regression) in the browser
|
|
* and writes 3d-<name>.png into tests/3d-regression/output/webgl/. This spec
|
|
* never compares pixels: the gates live in `npm run 3d:check:webgl`
|
|
* (browser-regression, once baseline-webgl exists) and the informational
|
|
* `npm run 3d:check:parity` port-progress meter (expected ~100% changed while
|
|
* the FFP stubs render blank — the TDD red state).
|
|
*
|
|
* Anti-drift: no hand-typed scenario list. The committed
|
|
* tests/3d-regression/manifest.json (written by the native golden generator,
|
|
* cmp-guarded by scripts/test-3d-regression.sh) is the single source of truth,
|
|
* and the wasm registry is asserted against it name-by-name.
|
|
*/
|
|
|
|
import { test, expect } from './utils/fixtures';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
|
|
const MANIFEST_PATH = path.join(__dirname, '../3d-regression/manifest.json');
|
|
const OUTPUT_DIR = path.join(__dirname, '../3d-regression/output/webgl');
|
|
const APP_JS = path.join(__dirname, '../apps/3d-webgl/3d_webgl_test.js');
|
|
|
|
const MANIFEST: { width: number; height: number; scenarios: string[] } = JSON.parse(
|
|
fs.readFileSync(MANIFEST_PATH, 'utf8')
|
|
);
|
|
|
|
test.describe('3D WebGL Regression', () => {
|
|
test.skip(
|
|
!fs.existsSync(APP_JS),
|
|
'3d-webgl harness not built (run scripts/build-3d-webgl-test.sh)'
|
|
);
|
|
|
|
test.beforeAll(async () => {
|
|
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
});
|
|
|
|
test('module loads and registry matches the committed manifest', async ({ page }) => {
|
|
await page.goto('/3d-webgl/3d_webgl_test.html');
|
|
|
|
await page.waitForFunction(() => (window as any).threeDTest?.isReady(), undefined, {
|
|
timeout: 60000,
|
|
});
|
|
|
|
const total = await page.evaluate(() => (window as any).threeDTest.getTotalScenarios());
|
|
expect(total).toBe(MANIFEST.scenarios.length);
|
|
|
|
const names = await page.evaluate((count) => {
|
|
const t = (window as any).threeDTest;
|
|
return Array.from({ length: count }, (_, i) => t.getScenarioName(i));
|
|
}, total);
|
|
expect(names).toEqual(MANIFEST.scenarios);
|
|
|
|
const width = await page.evaluate(() => (window as any).threeDTest.getCanvasWidth());
|
|
const height = await page.evaluate(() => (window as any).threeDTest.getCanvasHeight());
|
|
expect(width).toBe(MANIFEST.width);
|
|
expect(height).toBe(MANIFEST.height);
|
|
});
|
|
|
|
/**
|
|
* Regression: WebGL context recreation (the 3D-viewer close/reopen model).
|
|
*
|
|
* The app's viewer close destroys the wxGLCanvas's WebGL context and DOM canvas;
|
|
* reopen creates fresh ones. The gl1 shim caches GL object names (FFP program,
|
|
* stream/scratch VBOs) in statics — pre-fix it kept using names owned by the
|
|
* destroyed context in the new one, so every draw died with INVALID_OPERATION and
|
|
* the reopened viewer rendered blank. recreateContext() reproduces exactly that
|
|
* (fresh canvas element + fresh context, no shim call — the shim must self-detect).
|
|
*
|
|
* Renders redraw-mini-board-navigator (the port-complete gate scenario: display
|
|
* lists, VBO models, grid, gizmo, materials) before and after recreation and
|
|
* requires the second render to be non-blank and pixel-identical-ish to the first.
|
|
*/
|
|
test('re-renders identically after WebGL context recreation', async ({ page }) => {
|
|
test.setTimeout(120000);
|
|
|
|
await page.goto('/3d-webgl/3d_webgl_test.html');
|
|
await page.waitForFunction(() => (window as any).threeDTest?.isReady(), undefined, {
|
|
timeout: 60000,
|
|
});
|
|
|
|
const name = 'redraw-mini-board-navigator';
|
|
const idx = MANIFEST.scenarios.indexOf(name);
|
|
expect(idx, `${name} must exist in the committed manifest`).toBeGreaterThanOrEqual(0);
|
|
|
|
// Snapshot the canvas backing store into an in-page buffer (one drawImage readback,
|
|
// CPU-backed 2D canvas — SwiftShader-safe) plus a 16x16 distinct-colour count.
|
|
const capture = (slot: string) =>
|
|
page.evaluate((s) => {
|
|
const el = document.getElementById('canvas') as HTMLCanvasElement;
|
|
const tmp = document.createElement('canvas');
|
|
tmp.width = el.width;
|
|
tmp.height = el.height;
|
|
const ctx = tmp.getContext('2d', { willReadFrequently: true })!;
|
|
ctx.drawImage(el, 0, 0);
|
|
const img = ctx.getImageData(0, 0, el.width, el.height).data;
|
|
(window as any)[s] = img;
|
|
const colors = new Set<string>();
|
|
for (let i = 0; i < 16; i++) {
|
|
for (let j = 0; j < 16; j++) {
|
|
const p =
|
|
(Math.floor((el.height * j) / 16) * el.width + Math.floor((el.width * i) / 16)) * 4;
|
|
colors.add(`${img[p]},${img[p + 1]},${img[p + 2]}`);
|
|
}
|
|
}
|
|
return colors.size;
|
|
}, slot);
|
|
|
|
const first = await page.evaluate((i) => (window as any).threeDTest.runScenario(i), idx);
|
|
expect(first, `runScenario(${idx}) [${name}] before recreation`).toBe(0);
|
|
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
const colorsBefore = await capture('__reopenFirst');
|
|
expect(colorsBefore, 'the scenario must render before the context swap').toBeGreaterThan(8);
|
|
// NOT in OUTPUT_DIR: the parity compare treats that dir as scenario renders.
|
|
await page
|
|
.locator('#canvas')
|
|
.screenshot({ path: path.join(OUTPUT_DIR, '..', `3d-ctx-recreate-before.png`) });
|
|
|
|
const rc = await page.evaluate(() => (window as any).threeDTest.recreateContext());
|
|
expect(rc, 'recreateContext() should mint a fresh WebGL context').toBe(0);
|
|
|
|
const second = await page.evaluate((i) => (window as any).threeDTest.runScenario(i), idx);
|
|
expect(second, `runScenario(${idx}) [${name}] after recreation`).toBe(0);
|
|
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
const colorsAfter = await capture('__reopenSecond');
|
|
await page
|
|
.locator('#canvas')
|
|
.screenshot({ path: path.join(OUTPUT_DIR, '..', `3d-ctx-recreate-after.png`) });
|
|
|
|
expect(
|
|
colorsAfter,
|
|
'the re-rendered scenario must not be blank — a uniform canvas means the gl1 shim ' +
|
|
'drew with GL object names from the destroyed context'
|
|
).toBeGreaterThan(8);
|
|
|
|
// Pixel-level identity check (same code, same context attributes → deterministic).
|
|
// Tolerance mirrors the suite's pixelmatch spirit: <0.1% differing pixels.
|
|
const diff = await page.evaluate(() => {
|
|
const a = (window as any).__reopenFirst as Uint8ClampedArray;
|
|
const b = (window as any).__reopenSecond as Uint8ClampedArray;
|
|
if (!a || !b || a.length !== b.length) return { changed: -1, total: 0 };
|
|
let changed = 0;
|
|
for (let p = 0; p < a.length; p += 4) {
|
|
if (
|
|
Math.abs(a[p] - b[p]) > 2 ||
|
|
Math.abs(a[p + 1] - b[p + 1]) > 2 ||
|
|
Math.abs(a[p + 2] - b[p + 2]) > 2
|
|
)
|
|
changed++;
|
|
}
|
|
return { changed, total: a.length / 4 };
|
|
});
|
|
expect(diff.changed, 'both captures must exist and agree in size').toBeGreaterThanOrEqual(0);
|
|
expect(
|
|
diff.changed / diff.total,
|
|
`render after context recreation must match the one before ` +
|
|
`(${diff.changed}/${diff.total} pixels differ)`
|
|
).toBeLessThan(0.001);
|
|
});
|
|
|
|
test('render all scenarios', async ({ page }) => {
|
|
test.setTimeout(300000);
|
|
|
|
await page.goto('/3d-webgl/3d_webgl_test.html');
|
|
await page.waitForFunction(() => (window as any).threeDTest?.isReady(), undefined, {
|
|
timeout: 60000,
|
|
});
|
|
|
|
for (const [i, name] of MANIFEST.scenarios.entries()) {
|
|
const rc = await page.evaluate((idx) => (window as any).threeDTest.runScenario(idx), i);
|
|
expect(rc, `runScenario(${i}) [${name}]`).toBe(0);
|
|
|
|
// One composite tick so the preserved drawing buffer is presentable.
|
|
await page.evaluate(() => new Promise(requestAnimationFrame));
|
|
|
|
await page
|
|
.locator('#canvas')
|
|
.screenshot({ path: path.join(OUTPUT_DIR, `3d-${name}.png`) });
|
|
}
|
|
});
|
|
});
|