diff --git a/docker/build.sh b/docker/build.sh index e916e76..46db5e5 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -188,6 +188,7 @@ compile_app() { # emsdk_env.sh, so the build shell would lack emcc/embuilder on PATH. Setting # EMSDK lets scripts/common/env.sh source /emsdk/emsdk_env.sh and activate the toolchain. docker compose -f docker/docker-compose.yml exec -e EMSDK=/emsdk \ + -e BUILD_3D_VIEWER="${BUILD_3D_VIEWER:-OFF}" \ kicad-wasm-builder \ "/workspace/scripts/kicad/build-${app}.sh" "${ARGS[@]}" diff --git a/kicad b/kicad index b75cff2..793c26b 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit b75cff2f5c1fcf121a47af7cf2bec13f0389505d +Subproject commit 793c26bbb4377c31ec32ff182ee06deb27edd3d9 diff --git a/scripts/kicad/build-kicad-target.sh b/scripts/kicad/build-kicad-target.sh index 39e2d8d..0c6ac07 100755 --- a/scripts/kicad/build-kicad-target.sh +++ b/scripts/kicad/build-kicad-target.sh @@ -387,6 +387,17 @@ if [ "${APP_NAME}" = "sym_convert" ]; then SYM_CONVERTER_CMAKE_FLAG="-DKICAD_SYM_CONVERTER_WASM=ON" fi +# 3D viewer (experimental): opt in with BUILD_3D_VIEWER=ON. Default OFF keeps +# existing/CI builds unchanged and the 3D stubs in place. When ON, the 3D +# viewer's fixed-function OpenGL renderer needs Emscripten's legacy GL emulation +# plus our immediate-mode shim (color-per-vertex + double-precision helpers). +BUILD_3D_VIEWER="${BUILD_3D_VIEWER:-OFF}" +GL3D_LINK_FLAGS="" +if [ "${BUILD_3D_VIEWER}" = "ON" ]; then + log_info "3D viewer ENABLED for WASM (BUILD_3D_VIEWER=ON)" + GL3D_LINK_FLAGS="-sLEGACY_GL_EMULATION --js-library ${WASM_LAYER}/shims/gl_immediate_shim.js" +fi + emcmake cmake "${KICAD_DIR}" \ ${CCACHE_OPTS} \ ${SYM_CONVERTER_CMAKE_FLAG} \ @@ -397,7 +408,7 @@ emcmake cmake "${KICAD_DIR}" \ -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ -DCMAKE_CXX_FLAGS="${EXTRA_FLAGS} -pthread -sUSE_ZLIB=1 -DKICAD_USE_PLATFORM_WASM=1${DIAG_DEFINES} -I${SYSROOT}/include -I${STUBS_DIR} -include ${STUBS_DIR}/char_traits_uint16_workaround.h" \ -DCMAKE_C_FLAGS="${EXTRA_FLAGS} -pthread -sUSE_ZLIB=1 -I${SYSROOT}/include -I${STUBS_DIR}" \ - -DCMAKE_EXE_LINKER_FLAGS="${LINKER_DEBUG_FLAGS} -pthread -sUSE_ZLIB=1 -sASYNCIFY=1 -sDYNCALLS=1 -sASYNCIFY_STACK_SIZE=65536 -sUSE_PTHREADS=1 -sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency' -sPTHREAD_POOL_SIZE_STRICT=0 -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=256MB -sMAXIMUM_MEMORY=4GB -sMAX_WEBGL_VERSION=2 -sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8','dynCall'] -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=['\$dynCall'] --bind -L${SYSROOT}/lib ${STUBS_BUILD}/libgit2_stub.a ${STUBS_BUILD}/libcurl_stub.a${APP_STUB_LINK} ${STUBS_BUILD}/libnng_stub.a ${EMBIND_OBJ}" \ + -DCMAKE_EXE_LINKER_FLAGS="${LINKER_DEBUG_FLAGS} -pthread -sUSE_ZLIB=1 -sASYNCIFY=1 -sDYNCALLS=1 -sASYNCIFY_STACK_SIZE=65536 -sUSE_PTHREADS=1 -sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency' -sPTHREAD_POOL_SIZE_STRICT=0 -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=256MB -sMAXIMUM_MEMORY=4GB -sMAX_WEBGL_VERSION=2 ${GL3D_LINK_FLAGS} -sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8','dynCall'] -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=['\$dynCall'] --bind -L${SYSROOT}/lib ${STUBS_BUILD}/libgit2_stub.a ${STUBS_BUILD}/libcurl_stub.a${APP_STUB_LINK} ${STUBS_BUILD}/libnng_stub.a ${EMBIND_OBJ}" \ -DCMAKE_PREFIX_PATH="${SYSROOT};${WX_BUILD}" \ -DwxWidgets_CONFIG_EXECUTABLE="${WX_BUILD}/wx-config" \ \ @@ -405,7 +416,7 @@ emcmake cmake "${KICAD_DIR}" \ -DKICAD_SPICE=OFF \ -DKICAD_USE_EGL=OFF \ -DKICAD_USE_BUNDLED_GLEW=ON \ - -DKICAD_BUILD_3D_VIEWER_WASM=OFF \ + -DKICAD_BUILD_3D_VIEWER_WASM=${BUILD_3D_VIEWER} \ -DKICAD_IPC_API=ON \ -DKICAD_USE_PCH=ON \ \ diff --git a/tests/kicad/3d-viewer.spec.ts b/tests/kicad/3d-viewer.spec.ts new file mode 100644 index 0000000..b274f5e --- /dev/null +++ b/tests/kicad/3d-viewer.spec.ts @@ -0,0 +1,151 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from './fixtures'; +import { clickMenuBarItem, clickMenuItem } from '../e2e/utils/element-tracker'; +import { injectFromSubmodule } from './utils/fs-inject'; +import { waitForBoardLoaded } from './utils/board-ready'; +import { waitForPcbnew } from './utils/pcbnew-ready'; + +/** + * 3D viewer e2e: load a real board in pcbnew, open the native 3D viewer + * (View → 3D Viewer / Alt+3), and verify a second top-level frame with its own + * WebGL canvas appears and renders the board. + * + * The 3D viewer (EDA_3D_VIEWER_FRAME) is a separate modeless KIWAY_PLAYER frame. + * In the wasm DOM port that surfaces as a new `#window-1` div in + * `#window-container` plus a dedicated `` (the main + * pcbnew board view is itself a wxGLCanvas, so we detect the viewer by the + * GL-canvas COUNT increasing, not by mere presence). + * + * Enabled by the WASM 3D-viewer build (BUILD_3D_VIEWER=ON → + * KICAD_BUILD_3D_VIEWER_WASM). With the bare board (no component STEP/WRL + * models — deferred), the viewer shows copper/silk/mask/edge geometry in 3D. + */ + +const KICAD_VERSION_DIR = '9.99'; +const PROJECT_DIR_MEMFS = `/home/kicad/documents/kicad/${KICAD_VERSION_DIR}/projects`; + +// Small, no-external-libs RF board — fast to load, no missing-libs dialog. +const DEMO = { name: 'microwave', dir: 'microwave', stem: 'microwave' } as const; + +async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors: string[] }): Promise { + const pcbFilename = `${DEMO.stem}.kicad_pcb`; + const proFilename = `${DEMO.stem}.kicad_pro`; + + await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${pcbFilename}`, + `${PROJECT_DIR_MEMFS}/${pcbFilename}`); + await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${proFilename}`, + `${PROJECT_DIR_MEMFS}/${proFilename}`); + + expect(await clickMenuBarItem(page, 'File'), 'File menu should be findable').toBe(true); + await page.waitForTimeout(400); + expect(await clickMenuItem(page, 'Open...'), 'Open… menu item should be findable').toBe(true); + + await page.waitForFunction(() => { + const registry = window.wxElementRegistry; + return !!registry && registry.findAll({ visible: true }) + .some((el) => el.typeName === 'wxFileDialog'); + }, null, { timeout: 15000 }); + await page.waitForTimeout(1000); + + const filenameInput = await page.evaluate(() => { + const registry = window.wxElementRegistry; + if (!registry) return null; + const text = registry.findAll({ visible: true }) + .find((el) => el.typeName === 'wxTextCtrl' && el.name === 'text'); + return text ? { x: text.centerX, y: text.centerY } : null; + }); + expect(filenameInput, 'filename text input should be visible').not.toBeNull(); + if (!filenameInput) throw new Error('filename text input not found'); + + await page.mouse.click(filenameInput.x, filenameInput.y); + await page.waitForTimeout(200); + await page.keyboard.type(pcbFilename); + await page.waitForTimeout(300); + await page.keyboard.press('Enter'); + await page.waitForTimeout(1000); + + const result = await waitForBoardLoaded(page, testLogger, 60000); + console.log(`[TEST] ${DEMO.name} board-ready result: ${result}`); +} + +function countGlCanvases(page: Page): Promise { + return page.evaluate(() => document.querySelectorAll('canvas[id^="glcanvas-"]').length); +} + +test.describe('3D viewer from pcbnew', () => { + // One 187 MB wasm runtime is already heavy; keep this serial and generous. + test.describe.configure({ mode: 'serial' }); + test.setTimeout(240000); + + test('opens the 3D viewer over a loaded board and renders it', async ({ page, testLogger }) => { + await page.goto('/kicad/pcbnew.html'); + await waitForPcbnew(page); + + await loadBoard(page, testLogger); + await page.screenshot({ path: `test-results/3d-viewer-00-board-loaded.png`, scale: 'device' }); + + 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); + + // The 3D reload runs through asyncify; give it time to build & render the + // board, then screenshot for visual validation (per CLAUDE.md). + await page.waitForTimeout(4000); + await page.screenshot({ path: `test-results/3d-viewer-${DEMO.name}.png`, scale: 'device' }); + + // The newest GL canvas is the 3D viewer's — screenshot it on its own too. + const newCanvas = page.locator('canvas[id^="glcanvas-"]').last(); + if (await newCanvas.isVisible().catch(() => false)) { + await newCanvas.screenshot({ path: `test-results/3d-viewer-${DEMO.name}-canvas.png` }) + .catch((e: unknown) => console.log(`[TEST] canvas screenshot failed: ${e}`)); + } + + // ── Console-clean gates (same signatures load-pcb.spec.ts guards). ── + const allLines = [...testLogger.consoleLogs, ...testLogger.errors]; + const aborts = allLines.filter((l) => l.includes('Aborted(')); + expect(aborts, `WASM aborted while opening the 3D viewer:\n${aborts.join('\n\n')}`).toEqual([]); + + const asyncifySignatures = [ + 'index out of bounds', 'indirect call to null', 'uncaught exception: unwind', + 'invalid state', 'is not a function', + ]; + const asyncifyErrors = allLines.filter((l) => + asyncifySignatures.some((sig) => l.toLowerCase().includes(sig))); + expect(asyncifyErrors, + `Asyncify corruption surfaced opening the 3D viewer:\n${asyncifyErrors.join('\n\n')}`) + .toEqual([]); + + // The 3D viewer stub logs this when the real viewer is NOT compiled in — + // its presence means BUILD_3D_VIEWER=ON didn't take effect. + const stubbed = allLines.filter((l) => l.includes('3D Viewer is not available')); + expect(stubbed, + `3D viewer is still stubbed (build not enabled?):\n${stubbed.join('\n')}`).toEqual([]); + }); +}); diff --git a/tests/kicad/utils/pcbnew-ready.ts b/tests/kicad/utils/pcbnew-ready.ts new file mode 100644 index 0000000..f4f2e85 --- /dev/null +++ b/tests/kicad/utils/pcbnew-ready.ts @@ -0,0 +1,47 @@ +import type { Page } from '@playwright/test'; +import { expect } from '../fixtures'; +import { clickByLabel } from '../../e2e/utils/element-tracker'; + +/** + * Shared pcbnew bring-up helpers. Mirrors the bring-up sequence proven in + * load-pcb.spec.ts so multiple kicad specs (load-pcb, 3d-viewer, ...) can wait + * for a ready pcbnew the same way without duplicating the timing logic. + */ + +/** + * KiCad first-run setup wizard. Click "Next >" until it's gone, then "Finish". + * If no wizard is present, both clicks no-op immediately. + */ +export async function dismissWizardIfPresent(page: Page): Promise { + for (let i = 0; i < 12; i++) { + const advanced = await clickByLabel(page, 'Next >'); + if (!advanced) break; + await page.waitForTimeout(400); + } + await clickByLabel(page, 'Finish'); + await page.waitForTimeout(800); +} + +/** + * Wait for pcbnew to boot: 2D canvas visible, element registry populated, the + * first-run wizard dismissed, and the main PcbFrame registered & visible. + */ +export async function waitForPcbnew(page: Page): Promise { + await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 }); + await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 }); + // Registry object ≠ app booted: wait for real UI entries (wizard or main + // frame) before dismissing — CI boots slower and the dismiss loop is bounded. + await page.waitForFunction(() => { + const registry = window.wxElementRegistry; + return !!registry && registry.findAll({}).length > 0; + }, null, { timeout: 150000 }); + await page.waitForTimeout(2500); + await dismissWizardIfPresent(page); + await page.waitForFunction(() => { + const registry = window.wxElementRegistry; + if (!registry) return false; + return registry.findAll({ visible: true }) + .some((el) => el.name === 'PcbFrame'); + }, null, { timeout: 90000 }); + await page.waitForTimeout(1500); +} diff --git a/tests/playwright-kicad.config.ts b/tests/playwright-kicad.config.ts index c4b2a21..058f3a3 100644 --- a/tests/playwright-kicad.config.ts +++ b/tests/playwright-kicad.config.ts @@ -87,6 +87,9 @@ const PCBNEW_FAMILY_SPECS = [ // boots pcbnew.html — must run on V8 (chromium-ci); on Firefox/x86 CI the // ~190M module OOMs at instantiation and #canvas never appears (run 27626037849). '**/pcbnew-move.spec.ts', + // 3D viewer specs boot pcbnew.html (3D-enabled build) — same V8 routing. + '**/3d-viewer.spec.ts', + '**/footprint-3d-preview.spec.ts', ]; const appsDir = 'apps'; diff --git a/wasm/shims/gl_immediate_shim.js b/wasm/shims/gl_immediate_shim.js index 0d71567..11970be 100644 --- a/wasm/shims/gl_immediate_shim.js +++ b/wasm/shims/gl_immediate_shim.js @@ -1,175 +1,415 @@ /** * gl_immediate_shim.js * - * Custom OpenGL immediate mode shims for KiCad WASM port. - * Fixes Emscripten LEGACY_GL_EMULATION issues: - * 1. Color-per-vertex requirement - injects color before each vertex automatically - * 2. Missing double-precision functions (glVertex2d, glVertex3d, glColor3d, glColor4d) + * Custom legacy-OpenGL shims for the KiCad WASM 3D viewer, layered on top of + * Emscripten's -sLEGACY_GL_EMULATION. Fills the gaps that emulation leaves: * - * Usage: emcc ... --js-library=lib/gl_immediate_shim.js + * 1. Immediate mode (glBegin/glEnd): inject the current color before each + * vertex (Emscripten requires a color per vertex) + double-precision + * glVertex / glColor overloads. + * + * 2. Display lists (glGenLists/glNewList/glCallList/...): NOT implemented by + * Emscripten at all. KiCad's 3D renderer compiles every board layer into a + * display list (client vertex arrays + glDrawArrays, or immediate mode for + * the grid) and replays it each frame. We record the GL calls between + * glNewList/glEndList and replay them on glCallList. For glDrawArrays the + * client array data may be freed after compile, so we snapshot it into a + * real (Emscripten-tracked) VBO at record time and replay from the VBO. + * + * 3. Fixed-function lighting entry points Emscripten lacks (glColorMaterial, + * glLightModeli) — stubbed so the link resolves; lighting falls back to + * flat/vertex color, which still yields a recognizable board. + * + * Usage: emcc ... --js-library=wasm/shims/gl_immediate_shim.js */ addToLibrary({ - // ================================================================== - // GLImmediateShim - State tracking and function wrapping - // ================================================================== - - $GLImmediateShim__deps: ['$GLImmediate', 'glBegin', 'glEnd', 'glVertex2f', 'glVertex3f', 'glColor3f', 'glColor4f'], + $GLImmediateShim__deps: [ + '$GL', '$GLImmediate', + 'glBegin', 'glEnd', 'glVertex2f', 'glVertex3f', 'glColor3f', 'glColor4f', + 'glNormal3f', 'glDrawArrays', 'glClear', + 'glEnable', 'glDisable', 'glBlendFunc', 'glBindTexture', 'glLineWidth', + 'glDepthMask', + 'glLightfv', 'glMaterialfv', 'glLightModelfv', 'glLightModelf', + 'glGenBuffers', 'glBindBuffer', 'glBufferData', + 'glEnableClientState', 'glDisableClientState', + 'glVertexPointer', 'glNormalPointer', 'glColorPointer', 'glTexCoordPointer', + 'malloc', 'free', + ], $GLImmediateShim__postset: 'GLImmediateShim.init();', $GLImmediateShim: { - // Current color state (persistent across vertices) + // ---- GL constants we use directly ---- + GL_ARRAY_BUFFER: 0x8892, + GL_STATIC_DRAW: 0x88E4, + GL_FLOAT: 0x1406, + GL_COMPILE: 0x1300, + // Client-state enums, indexed by GLImmediate attribute slot. + CLIENT_STATE: [0x8074 /*VERTEX*/, 0x8075 /*NORMAL*/, 0x8076 /*COLOR*/, 0x8078 /*TEXCOORD*/], + + // ---- immediate-mode color state ---- currentColor: null, - - // Track if we're inside glBegin/glEnd block inBeginEnd: false, - - // Track if color was called since the last vertex - // This prevents double-injection when code already calls color per vertex colorCalledSinceLastVertex: false, - // Original functions we're wrapping - origFns: {}, + // ---- display-list state ---- + lists: null, // id -> array of replay closures + listBuffers: null, // id -> array of malloc'd HEAP ptrs to free on delete + nextListId: 1, + compiling: 0, // id currently being compiled, or 0 + tmpIdPtr: 0, // scratch HEAP slot for glGenBuffers output + lastImmediateContext: null, // GL context GLImmediate's FFP programs were built for - // Initialization flag + // ---- temporary diagnostics (remove once rendering is confirmed) ---- + dbg: { clears: 0, newLists: 0, snaps: 0, calls: 0, replayDraws: 0 }, + dbgLog: function(cat, msg) { + var d = GLImmediateShim.dbg; + if (d[cat] === undefined) d[cat] = 0; + d[cat]++; + if (d[cat] <= 30) console.log('[DL] ' + msg); + else if (d[cat] === 31) console.log('[DL] ...(' + cat + ' further logs suppressed)'); + }, + + origFns: {}, initialized: false, init: function() { if (GLImmediateShim.initialized) return; - if (typeof GLImmediate === 'undefined') { - // GLImmediate not ready yet, will be called again - console.log('[GLImmediateShim] Waiting for GLImmediate...'); - return; - } + if (typeof GLImmediate === 'undefined') return; // retried via postset chain - console.log('[GLImmediateShim] Initializing OpenGL immediate mode shims'); - - // Initialize current color to white (OpenGL default) + console.log('[GLImmediateShim] Initializing legacy-GL + display-list shims'); GLImmediateShim.currentColor = new Float32Array([1.0, 1.0, 1.0, 1.0]); + GLImmediateShim.lists = {}; + GLImmediateShim.listBuffers = {}; - // Store original functions - GLImmediateShim.origFns = { - glBegin: _glBegin, - glEnd: _glEnd, - glVertex2f: _glVertex2f, - glVertex3f: _glVertex3f, - glColor3f: _glColor3f, - glColor4f: _glColor4f, + var S = GLImmediateShim; + S.origFns = { + glBegin: _glBegin, glEnd: _glEnd, + glVertex2f: _glVertex2f, glVertex3f: _glVertex3f, + glColor3f: _glColor3f, glColor4f: _glColor4f, + glNormal3f: _glNormal3f, glDrawArrays: _glDrawArrays, glClear: _glClear, + glEnable: _glEnable, glDisable: _glDisable, glBlendFunc: _glBlendFunc, + glBindTexture: _glBindTexture, glLineWidth: _glLineWidth, glDepthMask: _glDepthMask, }; - // Install shims - _glBegin = GLImmediateShim.shimBegin; - _glEnd = GLImmediateShim.shimEnd; - _glVertex2f = GLImmediateShim.shimVertex2f; - _glVertex3f = GLImmediateShim.shimVertex3f; - _glColor3f = GLImmediateShim.shimColor3f; - _glColor4f = GLImmediateShim.shimColor4f; + // Immediate-mode wrappers (color injection + display-list recording). + _glBegin = S.shimBegin; + _glEnd = S.shimEnd; + _glVertex2f = S.shimVertex2f; + _glVertex3f = S.shimVertex3f; + _glColor3f = S.shimColor3f; + _glColor4f = S.shimColor4f; + _glNormal3f = S.shimNormal3f; + _glDrawArrays = S.shimDrawArrays; + _glClear = S.shimClear; + + // State-setting calls that may appear inside a display list. + _glEnable = S.recWrap('glEnable'); + _glDisable = S.recWrap('glDisable'); + _glBlendFunc = S.recWrap('glBlendFunc'); + _glBindTexture = S.recWrap('glBindTexture'); + _glLineWidth = S.recWrap('glLineWidth'); + _glDepthMask = S.recWrap('glDepthMask'); + + // Emscripten's glemu THROWS on fixed-function light/material pnames it + // doesn't implement (e.g. glLightfv GL_SPECULAR/GL_POSITION). KiCad's + // lighting setup runs every frame right after the clear, so an unguarded + // throw aborts the whole 3D render before the board is drawn. Wrap these + // so the supported pnames still take effect and the rest are skipped. + S.origFns.glLightfv = _glLightfv; + _glLightfv = function(light, pname, params) { + try { S.origFns.glLightfv(light, pname, params); } + catch (e) { S.dbgLog('lightSkip', 'glLightfv skipped pname=0x' + pname.toString(16)); } + }; + S.origFns.glMaterialfv = _glMaterialfv; + _glMaterialfv = function(face, pname, params) { + try { S.origFns.glMaterialfv(face, pname, params); } + catch (e) { S.dbgLog('matSkip', 'glMaterialfv skipped pname=0x' + pname.toString(16)); } + }; + S.origFns.glLightModelfv = _glLightModelfv; + _glLightModelfv = function(pname, params) { + try { S.origFns.glLightModelfv(pname, params); } catch (e) {} + }; + S.origFns.glLightModelf = _glLightModelf; + _glLightModelf = function(pname, param) { + try { S.origFns.glLightModelf(pname, param); } catch (e) {} + }; GLImmediateShim.initialized = true; console.log('[GLImmediateShim] Initialized successfully'); }, - // ================================================================== - // Shim implementations - // ================================================================== + record: function(closure) { GLImmediateShim.lists[GLImmediateShim.compiling].push(closure); }, + freeListBuffers: function(list) { + var bufs = GLImmediateShim.listBuffers[list]; + if (bufs) { for (var i = 0; i < bufs.length; i++) _free(bufs[i]); bufs.length = 0; } + }, + + // Emscripten generates GLImmediate's per-context temp vertex/quad buffer pool + // (GL.currentContext.tempVertexBuffers1/2) only once, in GLEmulation.init(), + // for whichever context is current then — i.e. the FIRST WebGL context (the + // 2D board canvas). The 3D viewer owns a SECOND context that never gets the + // pool, so its first immediate-mode/client-array draw throws + // ("tempVertexBuffers1 is undefined"). Lazily generate the pool for any + // context that lacks it. (quads=true also sets up the quad index buffer the + // background gradient's GL_QUADS needs.) + ensureTempBuffers: function() { + if (typeof GL === 'undefined' || !GL.currentContext || typeof GLImmediate === 'undefined') return; + var ctx = GL.currentContext; + + // GLImmediate (legacy GL emulation) is single-context: GLEmulation.init() + // sets up its per-context temp buffers + FFP shader programs only for the + // FIRST WebGL context (KiCad's 2D board GAL). The 3D viewer owns a SECOND + // context that GLImmediate never initialised — we detect it as the one + // lacking the temp vertex buffer pool and patch GLImmediate to work on it. + if (ctx.tempVertexBuffers1 === undefined && typeof GL.generateTempBuffers === 'function') { + GL.generateTempBuffers(true, ctx); // per-context temp vertex/quad buffers + ctx.__glsForeign = true; // mark: not GLImmediate's home context + GLImmediateShim.dbgLog('tempBuf', 'initialised GLImmediate for the 3D viewer context'); + } + + if (ctx.__glsForeign) { + // createRenderer() reuses the bound user program (GL.currProgram) instead + // of building its own FFP program when one is set; the 2D GAL leaves its + // (other-context) program bound, which is "not linked" here → FFP draws + // silently produce nothing. Zero it every time we render on this context + // so the FFP builds + uses a program that belongs to THIS context. + GL.currProgram = 0; + + // The FFP renderer/program cache is global and holds the 2D context's + // program; drop it once so it regenerates for this context. + if (GLImmediateShim.lastImmediateContext !== ctx) { + GLImmediateShim.lastImmediateContext = ctx; + GLImmediate.currentRenderer = null; + GLImmediate.fixedFunctionProgram = 0; + if (GLImmediate.MapTreeLib && GLImmediate.rendererCache) { + GLImmediate.rendererCache = GLImmediate.MapTreeLib.create(); + } + // Force the regenerated FFP program to receive the current matrices / + // light state (a fresh program starts with default uniforms). + GLImmediate.matricesModified = true; + GLImmediate.lightingModified = true; + GLImmediateShim.dbgLog('ctxReset', 'reset FFP program cache for the 3D viewer context'); + } + } + }, + + // Generic recorder for state calls: when compiling, defer the original call + // to replay time; otherwise pass through immediately. The non-compiling path + // (the overwhelmingly common case — these wrap hot calls like glEnable that + // the 2D GAL makes constantly) forwards arguments without allocating. + recWrap: function(name) { + return function() { + var orig = GLImmediateShim.origFns[name]; + if (!GLImmediateShim.compiling) return orig.apply(null, arguments); + var args = Array.prototype.slice.call(arguments); + GLImmediateShim.record(function() { orig.apply(null, args); }); + }; + }, + + // ---- immediate mode ---- shimBegin: function(mode) { + if (GLImmediateShim.compiling) { GLImmediateShim.record(function() { _glBegin(mode); }); return; } + GLImmediateShim.ensureTempBuffers(); GLImmediateShim.inBeginEnd = true; GLImmediateShim.colorCalledSinceLastVertex = false; GLImmediateShim.origFns.glBegin(mode); }, - shimEnd: function() { + if (GLImmediateShim.compiling) { GLImmediateShim.record(function() { _glEnd(); }); return; } GLImmediateShim.inBeginEnd = false; GLImmediateShim.origFns.glEnd(); }, - shimColor3f: function(r, g, b) { - var c = GLImmediateShim.currentColor; - c[0] = r; - c[1] = g; - c[2] = b; - c[3] = 1.0; - - if (GLImmediateShim.inBeginEnd) { - // Mark that color was explicitly called for this vertex - GLImmediateShim.colorCalledSinceLastVertex = true; - } - + if (GLImmediateShim.compiling) { GLImmediateShim.record(function() { _glColor3f(r, g, b); }); return; } + var c = GLImmediateShim.currentColor; c[0] = r; c[1] = g; c[2] = b; c[3] = 1.0; + if (GLImmediateShim.inBeginEnd) GLImmediateShim.colorCalledSinceLastVertex = true; GLImmediateShim.origFns.glColor3f(r, g, b); }, - shimColor4f: function(r, g, b, a) { - var c = GLImmediateShim.currentColor; - c[0] = r; - c[1] = g; - c[2] = b; - c[3] = a; - - if (GLImmediateShim.inBeginEnd) { - // Mark that color was explicitly called for this vertex - GLImmediateShim.colorCalledSinceLastVertex = true; - } - + if (GLImmediateShim.compiling) { GLImmediateShim.record(function() { _glColor4f(r, g, b, a); }); return; } + var c = GLImmediateShim.currentColor; c[0] = r; c[1] = g; c[2] = b; c[3] = a; + if (GLImmediateShim.inBeginEnd) GLImmediateShim.colorCalledSinceLastVertex = true; GLImmediateShim.origFns.glColor4f(r, g, b, a); }, - - // Inject current color before each vertex (only if not already called) injectColor: function() { if (!GLImmediateShim.inBeginEnd) return; - - // Only inject color if it wasn't already called for this vertex if (!GLImmediateShim.colorCalledSinceLastVertex) { var c = GLImmediateShim.currentColor; GLImmediateShim.origFns.glColor4f(c[0], c[1], c[2], c[3]); } - - // Reset the flag for the next vertex GLImmediateShim.colorCalledSinceLastVertex = false; }, - shimVertex2f: function(x, y) { - GLImmediateShim.injectColor(); - GLImmediateShim.origFns.glVertex2f(x, y); + if (GLImmediateShim.compiling) { GLImmediateShim.record(function() { _glVertex2f(x, y); }); return; } + GLImmediateShim.injectColor(); GLImmediateShim.origFns.glVertex2f(x, y); }, - shimVertex3f: function(x, y, z) { - GLImmediateShim.injectColor(); - GLImmediateShim.origFns.glVertex3f(x, y, z); + if (GLImmediateShim.compiling) { GLImmediateShim.record(function() { _glVertex3f(x, y, z); }); return; } + GLImmediateShim.injectColor(); GLImmediateShim.origFns.glVertex3f(x, y, z); + }, + shimNormal3f: function(x, y, z) { + if (GLImmediateShim.compiling) { GLImmediateShim.record(function() { _glNormal3f(x, y, z); }); return; } + GLImmediateShim.origFns.glNormal3f(x, y, z); + }, + shimClear: function(mask) { + if (GLImmediateShim.compiling) { GLImmediateShim.record(function() { _glClear(mask); }); return; } + GLImmediateShim.ensureTempBuffers(); + var vp = '?', bw = '?', bh = '?'; + if (typeof GLctx !== 'undefined' && GLctx) { + try { var v = GLctx.getParameter(GLctx.VIEWPORT); vp = v[0] + ',' + v[1] + ',' + v[2] + ',' + v[3]; } catch (e) {} + bw = GLctx.drawingBufferWidth; bh = GLctx.drawingBufferHeight; + } + GLImmediateShim.dbgLog('clears', 'glClear mask=0x' + mask.toString(16) + + ' viewport=[' + vp + '] drawingBuffer=' + bw + 'x' + bh + + ' listsAlive=' + Object.keys(GLImmediateShim.lists).length); + GLImmediateShim.origFns.glClear(mask); + }, + + // ---- glDrawArrays: snapshot client arrays into a VBO when compiling ---- + shimDrawArrays: function(mode, first, count) { + if (!GLImmediateShim.compiling) { GLImmediateShim.origFns.glDrawArrays(mode, first, count); return; } + + var S = GLImmediateShim; + + // Snapshot each enabled client array into a PERSISTENT HEAP buffer (the + // source container may be freed once the list is compiled). At replay we + // re-point the client arrays at these buffers with NO VBO bound, so glemu + // takes its native client-array path: it reads each separate array from + // HEAP and builds its own interleaved per-context temp buffer. (Binding our + // own VBOs can't work — glemu binds a single GL_ARRAY_BUFFER, so separate + // vertex/normal/texcoord VBOs collide.) + var captured = []; + for (var slot = 0; slot < 4; slot++) { + if (!GLImmediate.enabledClientAttributes[slot]) continue; + var a = GLImmediate.clientAttributes[slot]; + if (!a) continue; + var size = a.size; + var stride = a.stride || size * 4; // GL_FLOAT arrays; stride 0 = tight + var heapPtr = _malloc(count * size * 4); // persists for the list's lifetime + var dst = heapPtr >> 2, src = (a.pointer + first * stride) >> 2, strideF = stride >> 2; + for (var i = 0; i < count; i++) + for (var j = 0; j < size; j++) + HEAPF32[dst + i * size + j] = HEAPF32[src + i * strideF + j]; + captured.push({ slot: slot, size: size, heapPtr: heapPtr }); + } + if (S.listBuffers[S.compiling]) + for (var b = 0; b < captured.length; b++) S.listBuffers[S.compiling].push(captured[b].heapPtr); + + S.dbgLog('snaps', 'snapshot list=' + S.compiling + ' mode=0x' + mode.toString(16) + + ' count=' + count + ' capturedSlots=[' + captured.map(function(c){return c.slot;}).join(',') + ']'); + + S.record(function() { + S.ensureTempBuffers(); + _glBindBuffer(S.GL_ARRAY_BUFFER, 0); // client arrays from HEAP, not a VBO + for (var k = 0; k < captured.length; k++) { + var c = captured[k]; + _glEnableClientState(S.CLIENT_STATE[c.slot]); + switch (c.slot) { + case 0: _glVertexPointer(c.size, S.GL_FLOAT, 0, c.heapPtr); break; + case 1: _glNormalPointer(S.GL_FLOAT, 0, c.heapPtr); break; + case 2: _glColorPointer(c.size, S.GL_FLOAT, 0, c.heapPtr); break; + case 3: _glTexCoordPointer(c.size, S.GL_FLOAT, 0, c.heapPtr); break; + } + } + S.origFns.glDrawArrays(mode, 0, count); + if (S.dbg.replayDraws < 8) { + S.dbg.replayDraws++; + var e = (typeof GLctx !== 'undefined' && GLctx) ? GLctx.getError() : -1; + var v0 = captured.length ? (captured[0].heapPtr >> 2) : 0; + var fv = captured.length + ? '(' + HEAPF32[v0].toFixed(3) + ',' + HEAPF32[v0 + 1].toFixed(3) + ',' + HEAPF32[v0 + 2].toFixed(3) + ')' + : 'none'; + var mv = GLImmediate.matrix && GLImmediate.matrix[0], pr = GLImmediate.matrix && GLImmediate.matrix[1]; + var fmt = function(m) { return m ? '[' + m[0].toFixed(2) + ',' + m[5].toFixed(2) + ',' + m[10].toFixed(2) + ',' + m[12].toFixed(2) + ',' + m[13].toFixed(2) + ',' + m[14].toFixed(2) + ']' : '?'; }; + console.log('[DL] replay draw count=' + count + ' slots=[' + + captured.map(function(c){return c.slot;}).join(',') + '] glError=0x' + e.toString(16) + + ' v0=' + fv + ' mv' + fmt(mv) + ' proj' + fmt(pr)); + } + for (var m = 0; m < captured.length; m++) + _glDisableClientState(S.CLIENT_STATE[captured[m].slot]); + }); }, }, // ================================================================== - // Double-precision vertex functions (missing in Emscripten) + // Display lists // ================================================================== + glGenLists__deps: ['$GLImmediateShim'], + glGenLists: function(range) { + if (range <= 0) return 0; + var base = GLImmediateShim.nextListId; + GLImmediateShim.nextListId += range; + for (var i = 0; i < range; i++) { + GLImmediateShim.lists[base + i] = []; + GLImmediateShim.listBuffers[base + i] = []; + } + return base; + }, + glIsList__deps: ['$GLImmediateShim'], + glIsList: function(list) { + return (GLImmediateShim.lists && GLImmediateShim.lists[list]) ? 1 : 0; + }, + glNewList__deps: ['$GLImmediateShim'], + glNewList: function(list, mode) { + GLImmediateShim.freeListBuffers(list); // rebuilding: free the prior data + GLImmediateShim.lists[list] = []; + GLImmediateShim.listBuffers[list] = []; + GLImmediateShim.compiling = list; + }, + glEndList__deps: ['$GLImmediateShim'], + glEndList: function() { GLImmediateShim.compiling = 0; }, + glCallList__deps: ['$GLImmediateShim'], + glCallList: function(list) { + var cmds = GLImmediateShim.lists[list]; + if (!cmds) return; + GLImmediateShim.dbgLog('calls', 'callList ' + list + ' cmds=' + cmds.length); + for (var i = 0; i < cmds.length; i++) cmds[i](); + }, + glDeleteLists__deps: ['$GLImmediateShim'], + glDeleteLists: function(list, range) { + for (var i = 0; i < range; i++) { + GLImmediateShim.freeListBuffers(list + i); + delete GLImmediateShim.lists[list + i]; + delete GLImmediateShim.listBuffers[list + i]; + } + }, + // ================================================================== + // Fixed-function lighting entry points Emscripten lacks (no-op stubs). + // Lighting falls back to flat/vertex color — still a recognizable board. + // ================================================================== + glColorMaterial: function(face, mode) {}, + glLightModeli: function(pname, param) {}, + glMaterialf: function(face, pname, param) {}, + + // ================================================================== + // GLU quadrics (sphere/cylinder/disk) — used for the navigation gizmo and + // rounded via/segment ends. Emscripten ships no GLU quadric runtime, so stub + // them: a non-null quadric handle plus no-op draws. The board's layer + // geometry comes from the display lists, not these, so it still renders. + // ================================================================== + gluNewQuadric: function() { return 1; }, + gluDeleteQuadric: function(q) {}, + gluQuadricDrawStyle: function(q, style) {}, + gluQuadricNormals: function(q, normals) {}, + gluCylinder: function(q, base, top, height, slices, stacks) {}, + gluDisk: function(q, inner, outer, slices, loops) {}, + gluSphere: function(q, radius, slices, stacks) {}, + + // ================================================================== + // Double-precision vertex/color overloads (missing in Emscripten) + // ================================================================== glVertex2d__deps: ['glVertex2f'], - glVertex2d: function(x, y) { - _glVertex2f(x, y); - }, - + glVertex2d: function(x, y) { _glVertex2f(x, y); }, glVertex3d__deps: ['glVertex3f'], - glVertex3d: function(x, y, z) { - _glVertex3f(x, y, z); - }, - + glVertex3d: function(x, y, z) { _glVertex3f(x, y, z); }, glVertex4d__deps: ['glVertex4f'], - glVertex4d: function(x, y, z, w) { - _glVertex4f(x, y, z, w); - }, - - // ================================================================== - // Double-precision color functions - // ================================================================== - + glVertex4d: function(x, y, z, w) { _glVertex4f(x, y, z, w); }, glColor3d__deps: ['glColor3f'], - glColor3d: function(r, g, b) { - _glColor3f(r, g, b); - }, - + glColor3d: function(r, g, b) { _glColor3f(r, g, b); }, glColor4d__deps: ['glColor4f'], - glColor4d: function(r, g, b, a) { - _glColor4f(r, g, b, a); - }, + glColor4d: function(r, g, b, a) { _glColor4f(r, g, b, a); }, }); // Ensure GLImmediateShim is included in the build