pcbjam/features/schematic/root.patch
2026-05-29 16:01:17 +02:00

2005 lines
79 KiB
Diff

diff --git a/docker/build.sh b/docker/build.sh
index 290112a..11104be 100755
--- a/docker/build.sh
+++ b/docker/build.sh
@@ -1,8 +1,13 @@
#!/bin/bash
-# Build KiCad WASM inside Docker container, then apply asyncify on host
-
-# Redirect all output to a log file (re-execs script with redirection)
-source "$(dirname "$0")/../scripts/common/logging.sh"
+# Build a KiCad editor (pcbnew or eeschema) inside Docker, then run asyncify
+# and friends on the host.
+#
+# Usage:
+# ./docker/build.sh # builds pcbnew (default)
+# ./docker/build.sh pcbnew # explicit
+# ./docker/build.sh eeschema # builds the schematic editor
+# ./docker/build.sh all # builds both, sequentially
+# ./docker/build.sh <app> -j 8 ... # any extra args are forwarded to build-*.sh
#
# The build is split into two phases:
# 1. Docker: Compile KiCad to WASM (without asyncify)
@@ -10,14 +15,34 @@ source "$(dirname "$0")/../scripts/common/logging.sh"
#
# Binaryen is downloaded automatically - no prerequisites needed.
+# Redirect all output to a log file (re-execs script with redirection)
+source "$(dirname "$0")/../scripts/common/logging.sh"
+
set -e
cd "$(dirname "$0")/.."
+# First positional arg is the app name; everything else is forwarded to build-*.sh.
+APP_NAME=""
+if [[ $# -gt 0 ]] && [[ "$1" != -* ]]; then
+ APP_NAME="$1"
+ shift
+fi
+APP_NAME="${APP_NAME:-pcbnew}"
+
+case "$APP_NAME" in
+ pcbnew|eeschema|all) ;;
+ *)
+ echo "Error: unknown app '$APP_NAME' (expected: pcbnew | eeschema | all)" >&2
+ exit 1
+ ;;
+esac
+
# Use branch name as Docker Compose project name for isolated containers/volumes
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD | tr '/' '-' | tr '[:upper:]' '[:lower:]')
export COMPOSE_PROJECT_NAME="kicad-wasm-${BRANCH_NAME}"
echo "Using Docker project: ${COMPOSE_PROJECT_NAME}"
+echo "Building app: ${APP_NAME}"
# Add -j 10 by default if no -j flag is given
ARGS=("$@")
@@ -45,31 +70,42 @@ docker compose -f docker/docker-compose.yml exec kicad-wasm-builder \
--exclude="tools/emsdk" \
/workspace-host/ /workspace/ || [ $? -eq 24 ]
-# Run build command (without asyncify - handled on host due to memory requirements)
-docker compose -f docker/docker-compose.yml exec kicad-wasm-builder \
- /workspace/scripts/kicad/build-pcbnew.sh "${ARGS[@]}"
+# Build one app: compile in container, then run host-side post-processing.
+build_app() {
+ local app="$1"
+ echo ""
+ echo "=== Building ${app} ==="
-# Copy output to host-accessible directory
-# Note: pcbnew.wasm.debug.wasm contains DWARF debug info (generated with -gseparate-dwarf)
-echo "Copying build output to ./output/..."
-docker compose -f docker/docker-compose.yml exec kicad-wasm-builder \
- bash -c "mkdir -p /workspace/output && \
- cp /workspace/build-wasm/kicad-pcbnew/pcbnew/pcbnew.{js,wasm,wasm.debug.wasm,wasm.map,worker.js} /workspace/output/ 2>/dev/null || \
- cp /workspace/build-wasm/kicad-pcbnew/pcbnew/pcbnew.{js,wasm} /workspace/output/; \
- cp /workspace/build-wasm/kicad-pcbnew/resources/images.tar.gz /workspace/output/ 2>/dev/null || true; \
- cp /workspace/build-wasm/wxwidgets/build/wasm/wx.js /workspace/output/ 2>/dev/null || true"
-
-# Inject dynCall shims into pcbnew.js
-# This fixes "dynCall_* is not defined" errors in Emscripten 4.x
-./scripts/common/inject-dyncall-shims.sh output/pcbnew.js
-
-# Apply wasm-emscripten-finalize on host (skipped in Docker due to memory limits)
-# This is done on the host because finalize with DWARF needs significant RAM
-./scripts/common/apply-finalize.sh output/pcbnew.wasm output/pcbnew.wasm
-
-# Apply asyncify transformation on host
-# This is done on the host because wasm-opt --asyncify needs significant RAM
-./scripts/common/apply-asyncify.sh output/pcbnew.wasm output/pcbnew.wasm
+ # Run build command (without asyncify - handled on host due to memory requirements)
+ docker compose -f docker/docker-compose.yml exec kicad-wasm-builder \
+ "/workspace/scripts/kicad/build-${app}.sh" "${ARGS[@]}"
+
+ # Copy output to host-accessible directory.
+ # ${app}.wasm.debug.wasm contains DWARF debug info (when built with -gseparate-dwarf).
+ echo "Copying ${app} build output to ./output/..."
+ docker compose -f docker/docker-compose.yml exec kicad-wasm-builder \
+ bash -c "mkdir -p /workspace/output && \
+ cp /workspace/build-wasm/kicad-${app}/${app}/${app}.{js,wasm,wasm.debug.wasm,wasm.map,worker.js} /workspace/output/ 2>/dev/null || \
+ cp /workspace/build-wasm/kicad-${app}/${app}/${app}.{js,wasm} /workspace/output/; \
+ cp /workspace/build-wasm/kicad-${app}/resources/images.tar.gz /workspace/output/ 2>/dev/null || true; \
+ cp /workspace/build-wasm/wxwidgets/build/wasm/wx.js /workspace/output/ 2>/dev/null || true"
+
+ # Inject dynCall shims (fixes "dynCall_* is not defined" errors in Emscripten 4.x)
+ ./scripts/common/inject-dyncall-shims.sh "output/${app}.js"
+
+ # Apply wasm-emscripten-finalize on host (skipped in Docker due to memory limits)
+ ./scripts/common/apply-finalize.sh "output/${app}.wasm" "output/${app}.wasm"
+
+ # Apply asyncify transformation on host
+ ./scripts/common/apply-asyncify.sh "output/${app}.wasm" "output/${app}.wasm"
+}
+
+if [[ "${APP_NAME}" == "all" ]]; then
+ build_app pcbnew
+ build_app eeschema
+else
+ build_app "${APP_NAME}"
+fi
echo ""
echo "Build complete. Output files in ./output/"
diff --git a/scripts/create-feature-patches.sh b/scripts/create-feature-patches.sh
index f6aa3e7..effae4e 100755
--- a/scripts/create-feature-patches.sh
+++ b/scripts/create-feature-patches.sh
@@ -9,15 +9,35 @@ FEATURE_DIR="features/${BRANCH}"
mkdir -p "$FEATURE_DIR"
-# Root repo patch (exclude submodules)
-git diff HEAD -- ':!kicad' ':!wxwidgets' > "$FEATURE_DIR/root.patch"
+# Root repo patch (exclude submodules and features/ — the latter would cause
+# the patch to contain itself recursively).
+git diff HEAD -- ':!kicad' ':!wxwidgets' ':!features' > "$FEATURE_DIR/root.patch"
-# Submodule patches (diff from upstream base)
-KICAD_BASE=$(git -C kicad log --format='%H' --author-not='viktor.vaczi@emergence-engineering.com' --author-not='noreply@anthropic.com' -1)
-git -C kicad diff $KICAD_BASE > "$FEATURE_DIR/kicad.patch"
+# Submodule patches: diff against main's recorded submodule sha so the patch
+# captures only this feature branch's submodule work (committed + uncommitted),
+# never upstream changes that landed on main.
+sub_diff() {
+ local sub="$1"
+ local out="$2"
+ local main_sha
+ main_sha=$(git ls-tree origin/main "$sub" 2>/dev/null | awk '{print $3}')
+ if [ -z "$main_sha" ]; then
+ echo "Warning: could not resolve origin/main:$sub — skipping $out" >&2
+ rm -f "$out"
+ return
+ fi
+ local cur_sha
+ cur_sha=$(git -C "$sub" rev-parse HEAD)
+ if [ "$main_sha" = "$cur_sha" ] && git -C "$sub" diff --quiet; then
+ echo "No feature-specific $sub changes (submodule pointer matches main, worktree clean) — skipping $(basename "$out")"
+ rm -f "$out"
+ return
+ fi
+ git -C "$sub" diff "$main_sha" > "$out"
+}
-WX_BASE="v3.2.6"
-git -C wxwidgets diff $WX_BASE > "$FEATURE_DIR/wxwidgets.patch"
+sub_diff kicad "$FEATURE_DIR/kicad.patch"
+sub_diff wxwidgets "$FEATURE_DIR/wxwidgets.patch"
echo "Patches created in $FEATURE_DIR/"
ls -la "$FEATURE_DIR"/*.patch 2>/dev/null || echo "No patches generated"
diff --git a/scripts/kicad/build-eeschema.sh b/scripts/kicad/build-eeschema.sh
new file mode 100755
index 0000000..216bd1f
--- /dev/null
+++ b/scripts/kicad/build-eeschema.sh
@@ -0,0 +1,7 @@
+#!/bin/bash
+# Build KiCad Eeschema (schematic editor) for WebAssembly.
+# Thin wrapper around build-kicad-target.sh — see that script for options.
+
+set -e
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+exec "${SCRIPT_DIR}/build-kicad-target.sh" eeschema "$@"
diff --git a/scripts/kicad/build-kicad-target.sh b/scripts/kicad/build-kicad-target.sh
new file mode 100755
index 0000000..5685fe0
--- /dev/null
+++ b/scripts/kicad/build-kicad-target.sh
@@ -0,0 +1,395 @@
+#!/bin/bash
+# Build a KiCad editor (pcbnew or eeschema) for WebAssembly.
+#
+# Usage:
+# ./scripts/kicad/build-kicad-target.sh <app> [options]
+#
+# Args:
+# <app> pcbnew | eeschema (required)
+#
+# Options:
+# --full Full clean rebuild (dependencies + KiCad)
+# --clean-kicad Clean only KiCad build directory (not deps)
+# --build-deps Build dependencies (default: skip)
+# --debug Build with debug symbols (default)
+# --release Build optimized without debug symbols
+# --diag=... Diagnostic preprocessor flags (gal, coroutine, ctor, all)
+# -j N Parallel compilation jobs (default: 1)
+#
+# Each editor builds into its own tree: build-wasm/kicad-<app>/.
+# Per-editor extras live alongside generic stubs:
+# - wasm/bindings/<app>_embind.cpp (optional)
+# - wasm/stubs/<app>_frame_stub.cpp (optional, app-specific stubs)
+# - wasm/stubs/<app>_scripting_stub.cpp (optional, app-specific scripting stubs)
+
+set -e
+
+if [ -z "$1" ]; then
+ echo "Error: missing <app> argument (pcbnew | eeschema)" >&2
+ exit 1
+fi
+APP_NAME="$1"
+shift
+
+case "$APP_NAME" in
+ pcbnew|eeschema) ;;
+ *)
+ echo "Error: unknown app '$APP_NAME' (expected: pcbnew | eeschema)" >&2
+ exit 1
+ ;;
+esac
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "${SCRIPT_DIR}/../common/env.sh"
+source "${SCRIPT_DIR}/../common/versions.sh"
+source "${SCRIPT_DIR}/../common/functions.sh"
+
+KICAD_DIR="${PROJECT_ROOT}/kicad"
+KICAD_BUILD="${BUILD_ROOT}/kicad-${APP_NAME}"
+KICAD_STAMP="${BUILD_ROOT}/stamps/kicad-${APP_NAME}.stamp"
+WASM_LAYER="${PROJECT_ROOT}/wasm"
+WX_BUILD="${BUILD_ROOT}/wxwidgets-universal"
+
+# Parse arguments - incremental build by default (optimized for development)
+NO_CLEAN=1
+FULL_CLEAN=0
+SKIP_DEPS=1
+DEBUG=0
+DIAG_LIST=""
+while [[ $# -gt 0 ]]; do
+ case $1 in
+ --full)
+ FULL_CLEAN=1
+ NO_CLEAN=0
+ SKIP_DEPS=0
+ shift
+ ;;
+ --clean-kicad)
+ NO_CLEAN=0
+ shift
+ ;;
+ --build-deps)
+ SKIP_DEPS=0
+ shift
+ ;;
+ --debug)
+ DEBUG=1
+ shift
+ ;;
+ --release)
+ DEBUG_BUILD=0
+ export DEBUG_BUILD
+ shift
+ ;;
+ --diag=*)
+ DIAG_LIST="${1#--diag=}"
+ shift
+ ;;
+ --diag)
+ DIAG_LIST="$2"
+ shift 2
+ ;;
+ -j)
+ export JOBS="$2"
+ shift 2
+ ;;
+ -j*)
+ export JOBS="${1#-j}"
+ shift
+ ;;
+ *)
+ shift
+ ;;
+ esac
+done
+
+# Diagnostic preprocessor defines from --diag=<csv> (gal, coroutine, ctor, all).
+# These gate the KI_DIAG_* macros in kicad/include/kicad_wasm_diag.h. Output goes
+# to stdout ([KICAD_OUT] logs), never errors. Off by default.
+DIAG_DEFINES=""
+if [ -n "${DIAG_LIST}" ]; then
+ IFS=',' read -ra _diag_cats <<< "${DIAG_LIST}"
+ for _cat in "${_diag_cats[@]}"; do
+ case "${_cat}" in
+ gal) DIAG_DEFINES="${DIAG_DEFINES} -DKICAD_DIAG_GAL=1" ;;
+ coroutine) DIAG_DEFINES="${DIAG_DEFINES} -DKICAD_DIAG_COROUTINE=1" ;;
+ ctor) DIAG_DEFINES="${DIAG_DEFINES} -DKICAD_DIAG_CTOR=1" ;;
+ all) DIAG_DEFINES="${DIAG_DEFINES} -DKICAD_DIAG_GAL=1 -DKICAD_DIAG_COROUTINE=1 -DKICAD_DIAG_CTOR=1" ;;
+ "") ;;
+ *) log_warn "Unknown --diag category: '${_cat}' (valid: gal, coroutine, ctor, all)" ;;
+ esac
+ done
+ log_info "Diagnostic logging enabled:${DIAG_DEFINES}"
+fi
+
+log_info "Building app: ${APP_NAME}"
+log_info "Using ${JOBS} parallel jobs"
+
+# Step 1: Clean build directories
+if [ $FULL_CLEAN -eq 1 ]; then
+ log_info "Full clean: removing all stamps and build directories..."
+ rm -rf "${STAMPS_DIR}"/*
+ rm -rf "${BUILD_ROOT}/deps"/*
+ rm -rf "${BUILD_ROOT}/wxwidgets-universal"
+ rm -rf "${BUILD_ROOT}/stubs"
+ rm -rf "${KICAD_BUILD}"
+ rm -rf "${SYSROOT}"/*
+elif [ $NO_CLEAN -eq 0 ]; then
+ log_info "Cleaning KiCad ${APP_NAME} build directory..."
+ rm -rf "${KICAD_BUILD}" "${KICAD_STAMP}"
+else
+ log_info "Incremental build (use --clean-kicad or --full to clean)"
+fi
+
+# Step 2: Build dependencies
+# Note: --with-occ for OpenCASCADE, but NOT ngspice since KICAD_SPICE=OFF
+if [ $SKIP_DEPS -eq 0 ]; then
+ log_info "Building dependencies..."
+ "${SCRIPT_DIR}/../deps/build-all-deps.sh" --with-occ
+else
+ log_info "Skipping dependencies (use --build-deps or --full to build)"
+fi
+
+# Note: We don't check the KiCad stamp here for incremental builds.
+# CMake handles dependency tracking - it will detect changed source files
+# and only recompile what's needed. The stamp is created at the end for
+# scripts that want to know if KiCad was ever built successfully.
+
+# Step 4: Build wxWidgets (incremental - only recompiles changed files)
+log_info "Building wxWidgets..."
+"${SCRIPT_DIR}/../build-wxuniversal-wasm.sh" --no-clean
+
+log_info "Building KiCad ${APP_NAME} ${KICAD_VERSION} for WASM..."
+
+# Step 5: Set build type
+# Use environment DEBUG_BUILD if set, otherwise check local --debug flag
+# -fexceptions is required because wxWidgets is built with exceptions enabled
+# -matomics -mbulk-memory are required for shared memory (pthreads)
+# NOTE: We use -O1 for debug builds because -O0 produces WASM with too many
+# locals for V8/Chrome to compile (error: "local count too large").
+# -O1 keeps debug info but optimizes enough to stay under V8's limits.
+if [ "${DEBUG_BUILD:-0}" = "1" ] || [ $DEBUG -eq 1 ]; then
+ BUILD_TYPE="Debug"
+ EXTRA_FLAGS="-g -O1 -fexceptions -matomics -mbulk-memory"
+ # -gseparate-dwarf puts debug info in a separate .debug.wasm file
+ # This keeps the main WASM small (~200MB) while preserving full debug info
+ # DevTools loads the debug file on-demand when debugging
+ LINKER_DEBUG_FLAGS="-O1 -g -gseparate-dwarf -fexceptions"
+ log_info "Building KiCad in DEBUG mode (separate DWARF for smaller main binary)"
+else
+ BUILD_TYPE="Release"
+ EXTRA_FLAGS="-O2 -fexceptions -matomics -mbulk-memory"
+ # -O0 at link time skips wasm-opt (which can OOM on large WASM files)
+ # Compilation is still -O2 for optimized code, but we skip post-link wasm-opt
+ LINKER_DEBUG_FLAGS="-O0 -fexceptions"
+ log_info "Building KiCad in RELEASE mode (skipping wasm-opt due to memory limits)"
+fi
+
+# Step 6: Create build directory
+mkdir -p "${KICAD_BUILD}"
+cd "${KICAD_BUILD}"
+
+# Step 6.1: Build stub libraries for missing symbols
+# Generic stubs (libgit2, curl, nng) are shared across apps and built in BUILD_ROOT/stubs.
+# App-specific stubs (e.g. pcbnew_scripting_stub) build into the same directory but
+# are only linked in when the corresponding source exists.
+STUBS_DIR="${PROJECT_ROOT}/wasm/stubs"
+STUBS_BUILD="${BUILD_ROOT}/stubs"
+mkdir -p "${STUBS_BUILD}"
+
+log_info "Building stub libraries..."
+# Compile libgit2 stub
+emcc -c "${STUBS_DIR}/libgit2_stub.c" -o "${STUBS_BUILD}/libgit2_stub.o"
+emar rcs "${STUBS_BUILD}/libgit2_stub.a" "${STUBS_BUILD}/libgit2_stub.o"
+
+# Compile curl stub
+emcc -c "${STUBS_DIR}/curl_stub.c" -o "${STUBS_BUILD}/curl_stub.o"
+emar rcs "${STUBS_BUILD}/libcurl_stub.a" "${STUBS_BUILD}/curl_stub.o"
+
+# Note: GLU tesselator is now implemented in wasm/stubs/glu_wasm_impl.cpp
+# It's compiled as part of the GAL library (requires KiCad headers)
+
+# Compile NNG stub (IPC API requires NNG but sockets don't work in WASM)
+emcc -c -I"${STUBS_DIR}" "${STUBS_DIR}/nng_stub.c" -o "${STUBS_BUILD}/nng_stub.o"
+emar rcs "${STUBS_BUILD}/libnng_stub.a" "${STUBS_BUILD}/nng_stub.o"
+
+# wx flags for any C++ stubs that include wx headers
+WX_CXXFLAGS=$("${WX_BUILD}/wx-config" --cxxflags 2>/dev/null || echo "-I${WX_BUILD}/lib/wx/include/emscripten-unicode-static-3.2 -I${PROJECT_ROOT}/wxwidgets/include")
+
+# App-specific stubs:
+# - pcbnew: pcbnew_scripting_stub.cpp (action-plugin scripting placeholders)
+# - eeschema: eeschema_frame_stub.cpp (placeholder; grows as linker dictates)
+APP_STUB_LINK=""
+APP_SCRIPTING_STUB_SRC="${STUBS_DIR}/${APP_NAME}_scripting_stub.cpp"
+if [ -f "${APP_SCRIPTING_STUB_SRC}" ]; then
+ log_info "Building app scripting stub: ${APP_NAME}_scripting_stub.cpp"
+ em++ -c ${WX_CXXFLAGS} "${APP_SCRIPTING_STUB_SRC}" -o "${STUBS_BUILD}/${APP_NAME}_scripting_stub.o"
+ emar rcs "${STUBS_BUILD}/lib${APP_NAME}_scripting_stub.a" "${STUBS_BUILD}/${APP_NAME}_scripting_stub.o"
+ APP_STUB_LINK="${APP_STUB_LINK} ${STUBS_BUILD}/lib${APP_NAME}_scripting_stub.a"
+fi
+
+APP_FRAME_STUB_SRC="${STUBS_DIR}/${APP_NAME}_frame_stub.cpp"
+if [ -f "${APP_FRAME_STUB_SRC}" ] && [ -s "${APP_FRAME_STUB_SRC}" ]; then
+ log_info "Building app frame stub: ${APP_NAME}_frame_stub.cpp"
+ em++ -c ${WX_CXXFLAGS} "${APP_FRAME_STUB_SRC}" -o "${STUBS_BUILD}/${APP_NAME}_frame_stub.o"
+ emar rcs "${STUBS_BUILD}/lib${APP_NAME}_frame_stub.a" "${STUBS_BUILD}/${APP_NAME}_frame_stub.o"
+ APP_STUB_LINK="${APP_STUB_LINK} ${STUBS_BUILD}/lib${APP_NAME}_frame_stub.a"
+fi
+
+log_info "Stub libraries built"
+
+# Step 6.2: Replace Emscripten's wasm-opt with stub to bypass asyncify transformation
+# This allows Emscripten to generate JS with Asyncify runtime, but we run the real
+# wasm-opt --asyncify on the host where more RAM is available (needs 50GB+ for KiCad)
+if [ -z "${EMSDK}" ]; then
+ log_error "EMSDK environment variable is not set."
+ exit 1
+fi
+EMSDK_WASM_OPT="${EMSDK}/upstream/bin/wasm-opt"
+if [ -f "${EMSDK_WASM_OPT}" ] && [ ! -f "${EMSDK_WASM_OPT}.real" ]; then
+ log_info "Backing up real wasm-opt..."
+ mv "${EMSDK_WASM_OPT}" "${EMSDK_WASM_OPT}.real"
+fi
+# Always copy the latest stub (in case it was updated)
+cp "${STUBS_DIR}/wasm-opt-stub.sh" "${EMSDK_WASM_OPT}"
+chmod +x "${EMSDK_WASM_OPT}"
+log_info "wasm-opt stub installed (asyncify will run on host)"
+
+# Step 6.3: Replace wasm-emscripten-finalize with stub (same pattern as wasm-opt)
+# This tool also OOMs on large WASM with debug symbols, so we run it on the host
+EMSDK_FINALIZE="${EMSDK}/upstream/bin/wasm-emscripten-finalize"
+if [ -f "${EMSDK_FINALIZE}" ] && [ ! -f "${EMSDK_FINALIZE}.real" ]; then
+ log_info "Backing up real wasm-emscripten-finalize..."
+ mv "${EMSDK_FINALIZE}" "${EMSDK_FINALIZE}.real"
+fi
+# Always copy the latest stub (in case it was updated)
+cp "${STUBS_DIR}/wasm-emscripten-finalize-stub.sh" "${EMSDK_FINALIZE}"
+chmod +x "${EMSDK_FINALIZE}"
+log_info "wasm-emscripten-finalize stub installed (finalize will run on host)"
+
+# Step 6.5: Verify WASM support is in KiCad fork
+# The kicad submodule should already have WASM port detection and kiplatform support
+KICAD_CMAKE="${KICAD_DIR}/CMakeLists.txt"
+if ! grep -q "msw|qt|gtk|osx|wasm" "${KICAD_CMAKE}"; then
+ log_error "KiCad fork is missing WASM port detection support."
+ log_error "Please ensure the kicad submodule has WASM modifications."
+ exit 1
+fi
+KIPLATFORM_CMAKE="${KICAD_DIR}/libs/kiplatform/CMakeLists.txt"
+if ! grep -q "KICAD_WX_PORT STREQUAL wasm" "${KIPLATFORM_CMAKE}"; then
+ log_error "KiCad fork is missing kiplatform WASM support."
+ log_error "Please ensure the kicad submodule has WASM modifications."
+ exit 1
+fi
+log_info "KiCad WASM support verified"
+
+# Embind object — built after CMake configure runs (so config.h exists). The
+# linker line below references "${STUBS_BUILD}/${APP_NAME}_embind.o" so we
+# create an empty placeholder when the source is missing, to keep the link
+# line stable across apps.
+EMBIND_OBJ="${STUBS_BUILD}/${APP_NAME}_embind.o"
+EMBIND_SRC="${PROJECT_ROOT}/wasm/bindings/${APP_NAME}_embind.cpp"
+
+# Step 7: Configure KiCad with CMake
+# We use CMAKE_MODULE_PATH to inject our compatibility layer
+log_info "Configuring KiCad with CMake..."
+
+# Use ccache if available (CMAKE_*_COMPILER_LAUNCHER is the proper CMake way)
+CCACHE_OPTS=""
+if command -v ccache &> /dev/null; then
+ CCACHE_OPTS="-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache"
+ log_info "Using ccache for compilation"
+fi
+
+emcmake cmake "${KICAD_DIR}" \
+ ${CCACHE_OPTS} \
+ -DCMAKE_BUILD_TYPE=${BUILD_TYPE} \
+ -DCMAKE_INSTALL_PREFIX="${SYSROOT}" \
+ -DCMAKE_MODULE_PATH="${WASM_LAYER}/cmake" \
+ -DSYSROOT="${SYSROOT}" \
+ -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_PREFIX_PATH="${SYSROOT};${WX_BUILD}" \
+ -DwxWidgets_CONFIG_EXECUTABLE="${WX_BUILD}/wx-config" \
+ \
+ -DKICAD_BUILD_QA_TESTS=OFF \
+ -DKICAD_SPICE=OFF \
+ -DKICAD_USE_EGL=OFF \
+ -DKICAD_USE_BUNDLED_GLEW=ON \
+ -DKICAD_BUILD_3D_VIEWER_WASM=OFF \
+ -DKICAD_IPC_API=ON \
+ -DKICAD_USE_PCH=ON \
+ \
+ -DZSTD_ROOT="${SYSROOT}" \
+ -DZSTD_INCLUDE_DIR="${SYSROOT}/include" \
+ -DZSTD_LIBRARY="${SYSROOT}/lib/libzstd.a" \
+ -DGLM_INCLUDE_DIR="${SYSROOT}/include" \
+ -DGLM_VERSION="0.9.9.8" \
+ -DBOOST_ROOT="${SYSROOT}" \
+ -DBoost_INCLUDE_DIR="${SYSROOT}/include" \
+ -DBoost_LIBRARY_DIR="${SYSROOT}/lib" \
+ -DBoost_NO_SYSTEM_PATHS=ON \
+ -DBoost_NO_BOOST_CMAKE=ON \
+ -DFREETYPE_INCLUDE_DIR_ft2build="${SYSROOT}/include/freetype2" \
+ -DFREETYPE_INCLUDE_DIR_freetype2="${SYSROOT}/include/freetype2" \
+ -DFREETYPE_LIBRARY="${SYSROOT}/lib/libfreetype.a" \
+ -DHarfBuzz_INCLUDE_DIR="${SYSROOT}/include/harfbuzz" \
+ -DHarfBuzz_LIBRARY="${SYSROOT}/lib/libharfbuzz.a" \
+ -DOCC_INCLUDE_DIR="${SYSROOT}/include/opencascade" \
+ -DOCC_LIBRARY_DIR="${SYSROOT}/lib" \
+ -DProtobuf_INCLUDE_DIR="${SYSROOT}/include" \
+ -DProtobuf_LIBRARY="${SYSROOT}/lib/libprotobuf.a" \
+ -DProtobuf_LITE_LIBRARY="${SYSROOT}/lib/libprotobuf-lite.a" \
+ -DProtobuf_PROTOC_EXECUTABLE="${SYSROOT}/bin/protoc" \
+ -DODBC_CONFIG:STRING="stub-for-wasm" \
+ -DODBCLIB:STRING="" \
+ -DODBC_CFLAGS:STRING="" \
+ -DODBC_LINK_FLAGS:STRING="" \
+ -DODBC_LIBRARIES:STRING="" \
+ \
+ -DBUILD_GITHUB_PLUGIN=OFF \
+ -DKICAD_PCM=OFF \
+ \
+ -DHAVE_STRCASECMP=1 \
+ -DHAVE_STRNCASECMP=1
+
+# Step 7.1: Compile Embind bindings (after CMake so config.h exists)
+# Exposes KiCad objects to JavaScript for future Pyodide integration.
+# When no app-specific source exists, build an empty object so the linker line
+# referencing ${APP_NAME}_embind.o doesn't break.
+if [ -f "${EMBIND_SRC}" ]; then
+ log_info "Compiling Embind bindings (${APP_NAME})..."
+ # Use the same includes and flags that KiCad uses
+ KICAD_INCLUDES="-I${KICAD_BUILD} -I${KICAD_DIR}/include -I${KICAD_DIR}/${APP_NAME} -I${KICAD_DIR}/common"
+ KICAD_INCLUDES+=" -I${KICAD_DIR}/libs/core/include -I${KICAD_DIR}/libs/kimath/include -I${KICAD_DIR}/libs/kiplatform/include"
+ KICAD_INCLUDES+=" -I${KICAD_DIR}/thirdparty/clipper2/Clipper2Lib/include"
+ KICAD_INCLUDES+=" -I${KICAD_DIR}/thirdparty/nlohmann_json"
+ KICAD_INCLUDES+=" -I${KICAD_DIR}/thirdparty/dynamic_bitset"
+ KICAD_INCLUDES+=" -I${KICAD_DIR}/thirdparty/nanodbc"
+ KICAD_INCLUDES+=" -I${KICAD_DIR}/thirdparty/picosha2"
+ KICAD_INCLUDES+=" -I${KICAD_DIR}/thirdparty"
+ KICAD_INCLUDES+=" -I${SYSROOT}/include"
+ # KiCad requires C++20 for concepts
+ em++ -std=c++20 -c ${EXTRA_FLAGS} ${WX_CXXFLAGS} ${KICAD_INCLUDES} "${EMBIND_SRC}" -o "${EMBIND_OBJ}"
+else
+ log_info "No embind source for ${APP_NAME} (expected at ${EMBIND_SRC}); using empty placeholder"
+ EMPTY_C="${STUBS_BUILD}/${APP_NAME}_embind_empty.c"
+ : > "${EMPTY_C}"
+ emcc -c "${EMPTY_C}" -o "${EMBIND_OBJ}"
+fi
+
+# Step 8: Build the app target
+log_info "Building ${APP_NAME}..."
+emmake make -j${JOBS} "${APP_NAME}"
+
+# Step 8.1: Build bitmap resources (images.tar.gz)
+# This creates the icon archive that KiCad loads at runtime
+log_info "Building bitmap resources..."
+emmake make bitmap_archive_build
+
+# Step 9: Create stamp file
+create_stamp "${KICAD_STAMP}"
+log_info "KiCad ${APP_NAME} build complete!"
+log_info "Output: ${KICAD_BUILD}/${APP_NAME}/${APP_NAME}.js"
diff --git a/scripts/kicad/build-pcbnew.sh b/scripts/kicad/build-pcbnew.sh
index fc1044f..4487c97 100755
--- a/scripts/kicad/build-pcbnew.sh
+++ b/scripts/kicad/build-pcbnew.sh
@@ -1,349 +1,7 @@
#!/bin/bash
-# Build KiCad PCBnew for WebAssembly
-# This builds the PCB editor as a standalone WASM application
-#
-# Usage:
-# ./scripts/kicad/build-pcbnew.sh [options]
-#
-# Options:
-# --full Full clean rebuild (dependencies + KiCad)
-# --clean-kicad Clean only KiCad build directory (not deps)
-# --build-deps Build dependencies (default: skip)
-# --debug Build with debug symbols (default)
-# --release Build optimized without debug symbols
-# -j N Parallel compilation jobs (default: 1)
-#
-# Defaults (optimized for development):
-# - Incremental build (no clean)
-# - Skip dependencies
-# - ccache enabled for faster rebuilds
-#
-# Incremental Build System:
-# - wxWidgets: configure runs once, make handles file-level dependencies
-# - KiCad: CMake tracks dependencies, only recompiles changed files
-# - ccache: Caches compiled objects for faster rebuilds
+# Build KiCad PCBnew for WebAssembly.
+# Thin wrapper around build-kicad-target.sh — see that script for options.
set -e
-
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-source "${SCRIPT_DIR}/../common/env.sh"
-source "${SCRIPT_DIR}/../common/versions.sh"
-source "${SCRIPT_DIR}/../common/functions.sh"
-
-KICAD_DIR="${PROJECT_ROOT}/kicad"
-KICAD_BUILD="${BUILD_ROOT}/kicad-pcbnew"
-KICAD_STAMP="${BUILD_ROOT}/stamps/kicad-pcbnew.stamp"
-WASM_LAYER="${PROJECT_ROOT}/wasm"
-WX_BUILD="${BUILD_ROOT}/wxwidgets-universal"
-
-# Parse arguments - incremental build by default (optimized for development)
-NO_CLEAN=1
-FULL_CLEAN=0
-SKIP_DEPS=1
-DEBUG=0
-DIAG_LIST=""
-while [[ $# -gt 0 ]]; do
- case $1 in
- --full)
- FULL_CLEAN=1
- NO_CLEAN=0
- SKIP_DEPS=0
- shift
- ;;
- --clean-kicad)
- NO_CLEAN=0
- shift
- ;;
- --build-deps)
- SKIP_DEPS=0
- shift
- ;;
- --debug)
- DEBUG=1
- shift
- ;;
- --release)
- DEBUG_BUILD=0
- export DEBUG_BUILD
- shift
- ;;
- --diag=*)
- DIAG_LIST="${1#--diag=}"
- shift
- ;;
- --diag)
- DIAG_LIST="$2"
- shift 2
- ;;
- -j)
- export JOBS="$2"
- shift 2
- ;;
- -j*)
- export JOBS="${1#-j}"
- shift
- ;;
- *)
- shift
- ;;
- esac
-done
-
-# Diagnostic preprocessor defines from --diag=<csv> (gal, coroutine, ctor, all).
-# These gate the KI_DIAG_* macros in kicad/include/kicad_wasm_diag.h. Output goes
-# to stdout ([KICAD_OUT] logs), never errors. Off by default.
-DIAG_DEFINES=""
-if [ -n "${DIAG_LIST}" ]; then
- IFS=',' read -ra _diag_cats <<< "${DIAG_LIST}"
- for _cat in "${_diag_cats[@]}"; do
- case "${_cat}" in
- gal) DIAG_DEFINES="${DIAG_DEFINES} -DKICAD_DIAG_GAL=1" ;;
- coroutine) DIAG_DEFINES="${DIAG_DEFINES} -DKICAD_DIAG_COROUTINE=1" ;;
- ctor) DIAG_DEFINES="${DIAG_DEFINES} -DKICAD_DIAG_CTOR=1" ;;
- all) DIAG_DEFINES="${DIAG_DEFINES} -DKICAD_DIAG_GAL=1 -DKICAD_DIAG_COROUTINE=1 -DKICAD_DIAG_CTOR=1" ;;
- "") ;;
- *) log_warn "Unknown --diag category: '${_cat}' (valid: gal, coroutine, ctor, all)" ;;
- esac
- done
- log_info "Diagnostic logging enabled:${DIAG_DEFINES}"
-fi
-
-log_info "Using ${JOBS} parallel jobs"
-
-# Step 1: Clean build directories
-if [ $FULL_CLEAN -eq 1 ]; then
- log_info "Full clean: removing all stamps and build directories..."
- rm -rf "${STAMPS_DIR}"/*
- rm -rf "${BUILD_ROOT}/deps"/*
- rm -rf "${BUILD_ROOT}/wxwidgets-universal"
- rm -rf "${BUILD_ROOT}/stubs"
- rm -rf "${KICAD_BUILD}"
- rm -rf "${SYSROOT}"/*
-elif [ $NO_CLEAN -eq 0 ]; then
- log_info "Cleaning KiCad PCBnew build directory..."
- rm -rf "${KICAD_BUILD}" "${KICAD_STAMP}"
-else
- log_info "Incremental build (use --clean-kicad or --full to clean)"
-fi
-
-# Step 2: Build dependencies
-# Note: --with-occ for OpenCASCADE, but NOT ngspice since KICAD_SPICE=OFF
-if [ $SKIP_DEPS -eq 0 ]; then
- log_info "Building dependencies..."
- "${SCRIPT_DIR}/../deps/build-all-deps.sh" --with-occ
-else
- log_info "Skipping dependencies (use --build-deps or --full to build)"
-fi
-
-# Note: We don't check the KiCad stamp here for incremental builds.
-# CMake handles dependency tracking - it will detect changed source files
-# and only recompile what's needed. The stamp is created at the end for
-# scripts that want to know if KiCad was ever built successfully.
-
-# Step 4: Build wxWidgets (incremental - only recompiles changed files)
-# The wxWidgets build script handles:
-# - Skipping configure if already configured
-# - make handles per-file dependency tracking
-# - ccache handles compilation caching
-log_info "Building wxWidgets..."
-"${SCRIPT_DIR}/../build-wxuniversal-wasm.sh" --no-clean
-
-log_info "Building KiCad PCBnew ${KICAD_VERSION} for WASM..."
-
-# Step 5: Set build type
-# Use environment DEBUG_BUILD if set, otherwise check local --debug flag
-# -fexceptions is required because wxWidgets is built with exceptions enabled
-# -matomics -mbulk-memory are required for shared memory (pthreads)
-# NOTE: We use -O1 for debug builds because -O0 produces WASM with too many
-# locals for V8/Chrome to compile (error: "local count too large").
-# -O1 keeps debug info but optimizes enough to stay under V8's limits.
-if [ "${DEBUG_BUILD:-0}" = "1" ] || [ $DEBUG -eq 1 ]; then
- BUILD_TYPE="Debug"
- EXTRA_FLAGS="-g -O1 -fexceptions -matomics -mbulk-memory"
- # -gseparate-dwarf puts debug info in a separate .debug.wasm file
- # This keeps the main WASM small (~200MB) while preserving full debug info
- # DevTools loads the debug file on-demand when debugging
- LINKER_DEBUG_FLAGS="-O1 -g -gseparate-dwarf -fexceptions"
- log_info "Building KiCad in DEBUG mode (separate DWARF for smaller main binary)"
-else
- BUILD_TYPE="Release"
- EXTRA_FLAGS="-O2 -fexceptions -matomics -mbulk-memory"
- # -O0 at link time skips wasm-opt (which can OOM on large WASM files)
- # Compilation is still -O2 for optimized code, but we skip post-link wasm-opt
- LINKER_DEBUG_FLAGS="-O0 -fexceptions"
- log_info "Building KiCad in RELEASE mode (skipping wasm-opt due to memory limits)"
-fi
-
-# Step 6: Create build directory
-mkdir -p "${KICAD_BUILD}"
-cd "${KICAD_BUILD}"
-
-# Step 6.1: Build stub libraries for missing symbols
-STUBS_DIR="${PROJECT_ROOT}/wasm/stubs"
-STUBS_BUILD="${BUILD_ROOT}/stubs"
-mkdir -p "${STUBS_BUILD}"
-
-log_info "Building stub libraries..."
-# Compile libgit2 stub
-emcc -c "${STUBS_DIR}/libgit2_stub.c" -o "${STUBS_BUILD}/libgit2_stub.o"
-emar rcs "${STUBS_BUILD}/libgit2_stub.a" "${STUBS_BUILD}/libgit2_stub.o"
-
-# Compile curl stub
-emcc -c "${STUBS_DIR}/curl_stub.c" -o "${STUBS_BUILD}/curl_stub.o"
-emar rcs "${STUBS_BUILD}/libcurl_stub.a" "${STUBS_BUILD}/curl_stub.o"
-
-# Note: GLU tesselator is now implemented in wasm/stubs/glu_wasm_impl.cpp
-# It's compiled as part of the GAL library (requires KiCad headers)
-
-# Compile PCBnew scripting stub (requires wxWidgets headers)
-WX_CXXFLAGS=$("${WX_BUILD}/wx-config" --cxxflags 2>/dev/null || echo "-I${WX_BUILD}/lib/wx/include/emscripten-unicode-static-3.2 -I${PROJECT_ROOT}/wxwidgets/include")
-em++ -c ${WX_CXXFLAGS} "${STUBS_DIR}/pcbnew_scripting_stub.cpp" -o "${STUBS_BUILD}/pcbnew_scripting_stub.o"
-emar rcs "${STUBS_BUILD}/libpcbnew_scripting_stub.a" "${STUBS_BUILD}/pcbnew_scripting_stub.o"
-
-# Compile NNG stub (IPC API requires NNG but sockets don't work in WASM)
-emcc -c -I"${STUBS_DIR}" "${STUBS_DIR}/nng_stub.c" -o "${STUBS_BUILD}/nng_stub.o"
-emar rcs "${STUBS_BUILD}/libnng_stub.a" "${STUBS_BUILD}/nng_stub.o"
-
-log_info "Stub libraries built"
-
-# Step 6.2: Replace Emscripten's wasm-opt with stub to bypass asyncify transformation
-# This allows Emscripten to generate JS with Asyncify runtime, but we run the real
-# wasm-opt --asyncify on the host where more RAM is available (needs 50GB+ for KiCad)
-if [ -z "${EMSDK}" ]; then
- log_error "EMSDK environment variable is not set."
- exit 1
-fi
-EMSDK_WASM_OPT="${EMSDK}/upstream/bin/wasm-opt"
-if [ -f "${EMSDK_WASM_OPT}" ] && [ ! -f "${EMSDK_WASM_OPT}.real" ]; then
- log_info "Backing up real wasm-opt..."
- mv "${EMSDK_WASM_OPT}" "${EMSDK_WASM_OPT}.real"
-fi
-# Always copy the latest stub (in case it was updated)
-cp "${STUBS_DIR}/wasm-opt-stub.sh" "${EMSDK_WASM_OPT}"
-chmod +x "${EMSDK_WASM_OPT}"
-log_info "wasm-opt stub installed (asyncify will run on host)"
-
-# Step 6.3: Replace wasm-emscripten-finalize with stub (same pattern as wasm-opt)
-# This tool also OOMs on large WASM with debug symbols, so we run it on the host
-EMSDK_FINALIZE="${EMSDK}/upstream/bin/wasm-emscripten-finalize"
-if [ -f "${EMSDK_FINALIZE}" ] && [ ! -f "${EMSDK_FINALIZE}.real" ]; then
- log_info "Backing up real wasm-emscripten-finalize..."
- mv "${EMSDK_FINALIZE}" "${EMSDK_FINALIZE}.real"
-fi
-# Always copy the latest stub (in case it was updated)
-cp "${STUBS_DIR}/wasm-emscripten-finalize-stub.sh" "${EMSDK_FINALIZE}"
-chmod +x "${EMSDK_FINALIZE}"
-log_info "wasm-emscripten-finalize stub installed (finalize will run on host)"
-
-# Step 6.5: Verify WASM support is in KiCad fork
-# The kicad submodule should already have WASM port detection and kiplatform support
-KICAD_CMAKE="${KICAD_DIR}/CMakeLists.txt"
-if ! grep -q "msw|qt|gtk|osx|wasm" "${KICAD_CMAKE}"; then
- log_error "KiCad fork is missing WASM port detection support."
- log_error "Please ensure the kicad submodule has WASM modifications."
- exit 1
-fi
-KIPLATFORM_CMAKE="${KICAD_DIR}/libs/kiplatform/CMakeLists.txt"
-if ! grep -q "KICAD_WX_PORT STREQUAL wasm" "${KIPLATFORM_CMAKE}"; then
- log_error "KiCad fork is missing kiplatform WASM support."
- log_error "Please ensure the kicad submodule has WASM modifications."
- exit 1
-fi
-log_info "KiCad WASM support verified"
-
-# Step 7: Configure KiCad with CMake
-# We use CMAKE_MODULE_PATH to inject our compatibility layer
-log_info "Configuring KiCad with CMake..."
-
-# Use ccache if available (CMAKE_*_COMPILER_LAUNCHER is the proper CMake way)
-CCACHE_OPTS=""
-if command -v ccache &> /dev/null; then
- CCACHE_OPTS="-DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache"
- log_info "Using ccache for compilation"
-fi
-
-emcmake cmake "${KICAD_DIR}" \
- ${CCACHE_OPTS} \
- -DCMAKE_BUILD_TYPE=${BUILD_TYPE} \
- -DCMAKE_INSTALL_PREFIX="${SYSROOT}" \
- -DCMAKE_MODULE_PATH="${WASM_LAYER}/cmake" \
- -DSYSROOT="${SYSROOT}" \
- -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}" \
- -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 ${STUBS_BUILD}/libpcbnew_scripting_stub.a ${STUBS_BUILD}/libnng_stub.a ${STUBS_BUILD}/pcbnew_embind.o" \
- -DCMAKE_PREFIX_PATH="${SYSROOT};${WX_BUILD}" \
- -DwxWidgets_CONFIG_EXECUTABLE="${WX_BUILD}/wx-config" \
- \
- -DKICAD_BUILD_QA_TESTS=OFF \
- -DKICAD_SPICE=OFF \
- -DKICAD_USE_EGL=OFF \
- -DKICAD_USE_BUNDLED_GLEW=ON \
- -DKICAD_BUILD_3D_VIEWER_WASM=OFF \
- -DKICAD_IPC_API=ON \
- \
- -DZSTD_ROOT="${SYSROOT}" \
- -DZSTD_INCLUDE_DIR="${SYSROOT}/include" \
- -DZSTD_LIBRARY="${SYSROOT}/lib/libzstd.a" \
- -DGLM_INCLUDE_DIR="${SYSROOT}/include" \
- -DGLM_VERSION="0.9.9.8" \
- -DBOOST_ROOT="${SYSROOT}" \
- -DBoost_INCLUDE_DIR="${SYSROOT}/include" \
- -DBoost_LIBRARY_DIR="${SYSROOT}/lib" \
- -DBoost_NO_SYSTEM_PATHS=ON \
- -DBoost_NO_BOOST_CMAKE=ON \
- -DFREETYPE_INCLUDE_DIR_ft2build="${SYSROOT}/include/freetype2" \
- -DFREETYPE_INCLUDE_DIR_freetype2="${SYSROOT}/include/freetype2" \
- -DFREETYPE_LIBRARY="${SYSROOT}/lib/libfreetype.a" \
- -DHarfBuzz_INCLUDE_DIR="${SYSROOT}/include/harfbuzz" \
- -DHarfBuzz_LIBRARY="${SYSROOT}/lib/libharfbuzz.a" \
- -DOCC_INCLUDE_DIR="${SYSROOT}/include/opencascade" \
- -DOCC_LIBRARY_DIR="${SYSROOT}/lib" \
- -DProtobuf_INCLUDE_DIR="${SYSROOT}/include" \
- -DProtobuf_LIBRARY="${SYSROOT}/lib/libprotobuf.a" \
- -DProtobuf_LITE_LIBRARY="${SYSROOT}/lib/libprotobuf-lite.a" \
- -DProtobuf_PROTOC_EXECUTABLE="${SYSROOT}/bin/protoc" \
- -DODBC_CONFIG:STRING="stub-for-wasm" \
- -DODBCLIB:STRING="" \
- -DODBC_CFLAGS:STRING="" \
- -DODBC_LINK_FLAGS:STRING="" \
- -DODBC_LIBRARIES:STRING="" \
- \
- -DBUILD_GITHUB_PLUGIN=OFF \
- -DKICAD_PCM=OFF \
- \
- -DHAVE_STRCASECMP=1 \
- -DHAVE_STRNCASECMP=1
-
-# Step 7.1: Compile Embind bindings (after CMake so config.h exists)
-# Exposes KiCad objects to JavaScript for future Pyodide integration
-EMBIND_SRC="${PROJECT_ROOT}/wasm/bindings/pcbnew_embind.cpp"
-if [ -f "$EMBIND_SRC" ]; then
- log_info "Compiling Embind bindings..."
- # Use the same includes and flags that KiCad uses
- KICAD_INCLUDES="-I${KICAD_BUILD} -I${KICAD_DIR}/include -I${KICAD_DIR}/pcbnew -I${KICAD_DIR}/common"
- KICAD_INCLUDES+=" -I${KICAD_DIR}/libs/core/include -I${KICAD_DIR}/libs/kimath/include -I${KICAD_DIR}/libs/kiplatform/include"
- KICAD_INCLUDES+=" -I${KICAD_DIR}/thirdparty/clipper2/Clipper2Lib/include"
- KICAD_INCLUDES+=" -I${KICAD_DIR}/thirdparty/nlohmann_json"
- KICAD_INCLUDES+=" -I${KICAD_DIR}/thirdparty/dynamic_bitset"
- KICAD_INCLUDES+=" -I${KICAD_DIR}/thirdparty/nanodbc"
- KICAD_INCLUDES+=" -I${KICAD_DIR}/thirdparty/picosha2"
- KICAD_INCLUDES+=" -I${KICAD_DIR}/thirdparty"
- KICAD_INCLUDES+=" -I${SYSROOT}/include"
- # KiCad requires C++20 for concepts
- em++ -std=c++20 -c ${EXTRA_FLAGS} ${WX_CXXFLAGS} ${KICAD_INCLUDES} "$EMBIND_SRC" -o "${STUBS_BUILD}/pcbnew_embind.o"
-fi
-
-# Step 8: Build pcbnew target
-log_info "Building pcbnew..."
-emmake make -j${JOBS} pcbnew
-
-# Step 8.1: Build bitmap resources (images.tar.gz)
-# This creates the icon archive that KiCad loads at runtime
-log_info "Building bitmap resources..."
-emmake make bitmap_archive_build
-
-# Step 9: Create stamp file
-create_stamp "${KICAD_STAMP}"
-log_info "KiCad PCBnew build complete!"
-log_info "Output: ${KICAD_BUILD}/pcbnew/pcbnew.js"
+exec "${SCRIPT_DIR}/build-kicad-target.sh" pcbnew "$@"
diff --git a/tests/apps/kicad/eeschema.html b/tests/apps/kicad/eeschema.html
new file mode 100644
index 0000000..399878d
--- /dev/null
+++ b/tests/apps/kicad/eeschema.html
@@ -0,0 +1,199 @@
+<!DOCTYPE html>
+<html lang="en-us">
+<head>
+ <meta charset="utf-8">
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
+ <title>KiCad Eeschema WASM</title>
+ <style>
+ .emscripten { padding-right: 0; margin-left: auto; margin-right: auto; display: block; }
+ div.emscripten { text-align: center; }
+ /* the canvas *must not* have any border or padding, or mouse coords will be wrong */
+ canvas.emscripten { border: 0px none; }
+
+ .window {
+ position: absolute;
+ pointer-events: none;
+ z-index: 10;
+ background-color: black;
+ overflow: hidden;
+ width: 0;
+ height: 0;
+ }
+
+ .window-canvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+ pointer-events: none;
+ }
+
+ #status {
+ position: fixed;
+ bottom: 10px;
+ left: 10px;
+ color: #fff;
+ font-family: monospace;
+ z-index: 1000;
+ background: rgba(0,0,0,0.7);
+ padding: 10px;
+ border-radius: 5px;
+ }
+
+ #progress {
+ width: 300px;
+ height: 20px;
+ background: #333;
+ margin-top: 5px;
+ }
+
+ #progress-bar {
+ height: 100%;
+ background: #4CAF50;
+ width: 0%;
+ transition: width 0.3s;
+ }
+ </style>
+</head>
+<body style="margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #1a1a2e;">
+ <div id="main-window" style="width: 100vw; height: 100vh; position: absolute; top: 0; left: 0;"></div>
+
+ <div id="status">
+ <div id="status-text">Initializing...</div>
+ <div id="progress"><div id="progress-bar"></div></div>
+ </div>
+
+ <div id="window-container"></div>
+
+ <script>
+ var mainWindow = document.getElementById('main-window');
+ var statusText = document.getElementById('status-text');
+ var progressBar = document.getElementById('progress-bar');
+
+ var showError = function(msg) {
+ console.error('[KICAD_ERROR] ' + msg);
+ statusText.textContent = 'Error: ' + msg;
+ statusText.style.color = 'red';
+ };
+
+ var createCanvas = function() {
+ var canvas = document.createElement('canvas');
+ canvas.id = 'canvas';
+ canvas.style.display = 'none';
+ // 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.style.width = width + 'px';
+ canvas.style.height = height + 'px';
+ canvas.oncontextmenu = function() { event.preventDefault(); };
+ canvas.addEventListener("webglcontextlost", function(e) {
+ showError('WebGL context lost. You will need to reload the page.');
+ e.preventDefault();
+ }, false);
+
+ mainWindow.appendChild(canvas);
+ Module.canvas = canvas;
+
+ console.log('[KICAD] preRun complete, canvas created: ' + width + 'x' + height);
+ };
+
+ var onRuntimeInitialized = function() {
+ console.log('[KICAD] Runtime initialized');
+ var canvas = Module.canvas;
+ canvas.style.display = 'block';
+ document.getElementById('status').style.display = 'none';
+ };
+
+ // Pre-fetched resource data (fetched before eeschema.js loads)
+ var resourceData = null;
+
+ // Start fetching images.tar.gz immediately (runs in parallel with WASM loading)
+ fetch('images.tar.gz')
+ .then(function(response) {
+ if (!response.ok) throw new Error('HTTP ' + response.status);
+ return response.arrayBuffer();
+ })
+ .then(function(buffer) {
+ resourceData = new Uint8Array(buffer);
+ console.log('[KICAD] Prefetched images.tar.gz (' + resourceData.length + ' bytes)');
+ })
+ .catch(function(err) {
+ console.warn('[KICAD] Could not prefetch images.tar.gz:', err.message);
+ });
+
+ // Write pre-fetched resources to FS (called in preRun after FS is available)
+ var writeResources = function() {
+ // Create directory structure matching KiCad's compiled-in KICAD_DATA path
+ // This path is baked in during CMake configuration
+ var resourcePath = '/workspace/build-wasm/sysroot/share/kicad/resources';
+ FS.mkdirTree(resourcePath);
+
+ // Write pre-fetched data if available
+ if (resourceData) {
+ FS.writeFile(resourcePath + '/images.tar.gz', resourceData);
+ console.log('[KICAD] Wrote images.tar.gz to ' + resourcePath);
+ } else {
+ console.warn('[KICAD] images.tar.gz not ready yet (WASM loaded faster than fetch)');
+ }
+ };
+
+ var Module = {
+ thisProgram: '/usr/bin/eeschema', // Fake absolute path for argv[0] (KiCad DEBUG check)
+
+ preRun: [createCanvas, writeResources],
+ postRun: [],
+
+ print: function(text) {
+ if (arguments.length > 1)
+ text = Array.prototype.slice.call(arguments).join(' ');
+ console.log('[KICAD_OUT] ' + text);
+ },
+
+ printErr: function(text) {
+ if (arguments.length > 1)
+ text = Array.prototype.slice.call(arguments).join(' ');
+ console.error('[KICAD_ERR] ' + text);
+ },
+
+ setStatus: function(text) {
+ console.log('[KICAD_STATUS] ' + text);
+ statusText.textContent = text;
+
+ // Parse progress from status text
+ var match = text.match(/(\d+)\/(\d+)/);
+ if (match) {
+ var pct = (parseInt(match[1]) / parseInt(match[2])) * 100;
+ progressBar.style.width = pct + '%';
+ }
+ },
+
+ totalDependencies: 0,
+ monitorRunDependencies: function(left) {
+ this.totalDependencies = Math.max(this.totalDependencies, left);
+ Module.setStatus(left ? 'Preparing... (' + (this.totalDependencies-left) + '/' + this.totalDependencies + ')' : 'All downloads complete.');
+ },
+
+ onRuntimeInitialized: onRuntimeInitialized,
+
+ // Required for locating .wasm and .worker.js files
+ locateFile: function(path) {
+ return path;
+ }
+ };
+
+ Module.setStatus('Downloading...');
+
+ window.onerror = function(msg, url, line) {
+ showError(msg + ' at ' + url + ':' + line);
+ Module.setStatus = function(text) {
+ if (text) Module.printErr('[post-exception status] ' + text);
+ };
+ return false;
+ };
+ </script>
+
+ <!-- wxWidgets WASM glue code (defines getConfigEntryLength, etc.) -->
+ <script src="wx.js"></script>
+ <script async src="eeschema.js"></script>
+</body>
+</html>
diff --git a/tests/kicad/eeschema.spec.ts b/tests/kicad/eeschema.spec.ts
new file mode 100644
index 0000000..61474f3
--- /dev/null
+++ b/tests/kicad/eeschema.spec.ts
@@ -0,0 +1,492 @@
+import type { Page } from '@playwright/test';
+import { test, expect } from './fixtures';
+import { clickByLabel, clickByTooltip, findByTooltip } from '../e2e/utils/element-tracker';
+
+/**
+ * Eeschema (schematic editor) WASM E2E Tests
+ *
+ * Mirrors pcbnew.spec.ts. The wxWidgets setup wizard is shared infrastructure,
+ * so the wizard flow is identical. Editor-specific checks (Appearance pane,
+ * exact toolbar count, reference-image diff, etc.) are intentionally omitted
+ * here until the eeschema UI surface is empirically pinned down.
+ */
+
+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;
+ }>;
+};
+
+type DiffRegion = {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+};
+
+type ScreenshotDifference = {
+ actualWidth: number;
+ actualHeight: number;
+ diffPixels: number;
+ diffRatio: number;
+ meanChannelDiff: number;
+};
+
+async function compareScreenshots(
+ page: Page,
+ beforePng: Buffer,
+ afterPng: Buffer,
+ region: DiffRegion
+): Promise<ScreenshotDifference> {
+ return page.evaluate(async ({ beforeBase64, afterBase64, 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 [before, after] = await Promise.all([
+ loadImage(beforeBase64),
+ loadImage(afterBase64),
+ ]);
+
+ if (before.width !== after.width || before.height !== after.height) {
+ return {
+ actualWidth: after.width,
+ actualHeight: after.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(before, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height);
+ const beforeData = context.getImageData(0, 0, canvas.width, canvas.height).data;
+
+ context.clearRect(0, 0, canvas.width, canvas.height);
+ context.drawImage(after, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height);
+ const afterData = context.getImageData(0, 0, canvas.width, canvas.height).data;
+
+ let diffPixels = 0;
+ let totalChannelDiff = 0;
+
+ for (let i = 0; i < beforeData.length; i += 4) {
+ const dr = Math.abs(beforeData[i] - afterData[i]);
+ const dg = Math.abs(beforeData[i + 1] - afterData[i + 1]);
+ const db = Math.abs(beforeData[i + 2] - afterData[i + 2]);
+ const da = Math.abs(beforeData[i + 3] - afterData[i + 3]);
+ const maxDiff = Math.max(dr, dg, db, da);
+
+ totalChannelDiff += dr + dg + db + da;
+
+ if (maxDiff > 16) {
+ diffPixels += 1;
+ }
+ }
+
+ return {
+ actualWidth: after.width,
+ actualHeight: after.height,
+ diffPixels,
+ diffRatio: diffPixels / (canvas.width * canvas.height),
+ meanChannelDiff: totalChannelDiff / beforeData.length,
+ };
+ }, {
+ beforeBase64: beforePng.toString('base64'),
+ afterBase64: afterPng.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: [],
+ };
+ }
+
+ 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,
+ }));
+
+ return {
+ elementStats: registry.getStats(),
+ renderedStats: registry.getRenderedStats ? registry.getRenderedStats() : null,
+ toolbars,
+ };
+ });
+}
+
+async function completeWizard(page: Page): Promise<void> {
+ await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
+ await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
+ await page.waitForTimeout(2000);
+
+ await page.screenshot({ path: 'test-results/eeschema-wizard-00-initial.png', scale: 'device' });
+
+ for (let i = 1; i <= 10; i++) {
+ let clicked = await clickByLabel(page, 'Next >');
+
+ if (!clicked) {
+ clicked = await clickByLabel(page, 'Finish');
+
+ if (clicked) {
+ await page.waitForTimeout(500);
+ await page.screenshot({
+ path: `test-results/eeschema-wizard-${String(i).padStart(2, '0')}-finish.png`,
+ scale: 'device'
+ });
+ }
+
+ break;
+ }
+
+ await page.waitForTimeout(500);
+ await page.screenshot({
+ path: `test-results/eeschema-wizard-${String(i).padStart(2, '0')}.png`,
+ scale: 'device'
+ });
+ }
+
+ await page.waitForTimeout(2000);
+}
+
+async function hideCursor(page: Page): Promise<void> {
+ await page.evaluate(() => {
+ document.documentElement.style.cursor = 'none';
+ document.body.style.cursor = 'none';
+ });
+}
+
+test.describe('Eeschema WASM', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/kicad/eeschema.html');
+ });
+
+ test('click through setup wizard to load Eeschema', async ({ page }) => {
+ await completeWizard(page);
+ const metrics = await getCanvasMetrics(page);
+ const registryMetrics = await getRegistryMetrics(page);
+
+ // Headless Firefox runs at dpr=1; pcbnew's stricter `> 1` check assumes a
+ // Retina-aware run. The eeschema MVP just needs to verify dpr is sane.
+ expect(metrics.dpr).toBeGreaterThanOrEqual(1);
+ expect(metrics.mainCanvas).not.toBeNull();
+ expect(metrics.glCanvas).not.toBeNull();
+ expect(registryMetrics.toolbars.length).toBeGreaterThanOrEqual(2);
+
+ 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);
+
+ await hideCursor(page);
+
+ // Capture a CSS-scale screenshot for visual review; no reference image
+ // is wired up yet (eeschema's chrome differs enough from pcbnew that
+ // sharing pcbnew's baseline isn't viable). Add a dedicated baseline
+ // here once the layout is finalised.
+ await page.screenshot({
+ path: 'test-results/eeschema-loaded-css.png',
+ scale: 'css'
+ });
+ await page.screenshot({ path: 'test-results/eeschema-loaded.png', scale: 'device' });
+
+ const canvasCount = await page.locator('canvas').count();
+ expect(canvasCount).toBeGreaterThan(0);
+ });
+
+ test('select draw wires and draw on the schematic', async ({ page, testLogger }) => {
+ await completeWizard(page);
+ await hideCursor(page);
+
+ await page.evaluate(() => {
+ const canvases = Array.from(document.querySelectorAll('canvas')).map((canvas) => {
+ const rect = canvas.getBoundingClientRect();
+ const style = window.getComputedStyle(canvas);
+ return {
+ id: canvas.id,
+ className: canvas.className,
+ display: style.display,
+ visibility: style.visibility,
+ width: canvas.width,
+ height: canvas.height,
+ rectX: rect.x,
+ rectY: rect.y,
+ rectWidth: rect.width,
+ rectHeight: rect.height,
+ shouldBeVisible: (canvas as HTMLCanvasElement).dataset?.shouldBeVisible ?? null,
+ };
+ });
+
+ console.log(`[TEST] canvas summary ${JSON.stringify(canvases)}`);
+
+ const registry = window.wxElementRegistry;
+ const topLevels = (registry?.findAll?.({}) ?? [])
+ .filter((item) => /Frame|Dialog|Wizard/.test(item.typeName))
+ .slice(0, 20)
+ .map((item) => ({
+ id: item.id,
+ typeName: item.typeName,
+ label: item.label,
+ name: item.name,
+ visible: item.visible,
+ enabled: item.enabled,
+ screenX: item.screenX,
+ screenY: item.screenY,
+ width: item.width,
+ height: item.height,
+ }));
+ const rendered = registry?.findAllRendered?.({}) ?? [];
+ const byType = rendered.reduce<Record<string, number>>((acc, item) => {
+ acc[item.elementType] = (acc[item.elementType] ?? 0) + 1;
+ return acc;
+ }, {});
+ const tools = rendered
+ .filter((item) => item.elementType === 'tool')
+ .slice(0, 20)
+ .map((item) => ({
+ id: item.id,
+ label: item.label,
+ tooltip: item.tooltip,
+ checked: item.checked,
+ enabled: item.enabled,
+ }));
+
+ console.log(`[TEST] top-level summary ${JSON.stringify(topLevels)}`);
+ console.log(`[TEST] rendered summary ${JSON.stringify({ count: rendered.length, byType, tools })}`);
+ });
+
+ await page.waitForFunction(() => {
+ const registry = window.wxElementRegistry;
+ if (!registry?.findAllRendered) {
+ return false;
+ }
+
+ return registry.findAllRendered({ elementType: 'tool' })
+ .some((tool) => tool.tooltip?.includes('Draw Wires'));
+ }, null, { timeout: 15000 });
+
+ const drawWiresTool = await findByTooltip(page, 'Draw Wires', { elementType: 'tool' });
+ expect(drawWiresTool).not.toBeNull();
+
+ if (!drawWiresTool) {
+ throw new Error('Draw Wires tool not found in rendered element registry');
+ }
+
+ // The registry carries checked state via a " [checked]" label suffix
+ // appended by wxAuiToolBar::OnPaint on Emscripten — no schema change.
+ const isToolChecked = (t: { label?: string } | null | undefined) =>
+ (t?.label ?? '').includes('[checked]');
+
+ expect(drawWiresTool.enabled).toBe(true);
+ expect(isToolChecked(drawWiresTool)).toBe(false);
+ const baselineErrorCount = testLogger.errors.length;
+
+ await page.screenshot({
+ path: 'test-results/eeschema-draw-wires-00-before-tool-click.png',
+ scale: 'device'
+ });
+
+ expect(await clickByTooltip(page, 'Draw Wires', { elementType: 'tool' })).toBe(true);
+
+ await expect.poll(async () => {
+ const tool = await findByTooltip(page, 'Draw Wires', { elementType: 'tool' });
+ return isToolChecked(tool);
+ }, {
+ message: 'Draw Wires tool should stay selected after the click',
+ timeout: 5000,
+ }).toBe(true);
+
+ await page.mouse.move(640, 360);
+ await page.waitForTimeout(600);
+
+ const selectedDrawWiresTool = await findByTooltip(page, 'Draw Wires', { elementType: 'tool' });
+ expect(isToolChecked(selectedDrawWiresTool)).toBe(true);
+
+ const afterToolClick = await page.screenshot({
+ path: 'test-results/eeschema-draw-wires-01-after-click.png',
+ scale: 'device'
+ });
+
+ const glCanvasId = await page.evaluate(() => {
+ 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;
+
+ return glCanvas?.id ?? null;
+ });
+
+ expect(glCanvasId).not.toBeNull();
+
+ if (!glCanvasId) {
+ throw new Error('Visible GL canvas not found');
+ }
+
+ const glCanvasBox = await page.locator(`#${glCanvasId}`).boundingBox();
+ expect(glCanvasBox).not.toBeNull();
+
+ if (!glCanvasBox) {
+ throw new Error('GL canvas bounding box unavailable');
+ }
+
+ const startPoint = {
+ x: Math.round(glCanvasBox.x + glCanvasBox.width * 0.28),
+ y: Math.round(glCanvasBox.y + glCanvasBox.height * 0.36),
+ };
+ const endPoint = {
+ x: Math.round(glCanvasBox.x + glCanvasBox.width * 0.48),
+ y: Math.round(glCanvasBox.y + glCanvasBox.height * 0.47),
+ };
+
+ await page.mouse.click(startPoint.x, startPoint.y);
+ await page.waitForTimeout(250);
+ await page.mouse.click(endPoint.x, endPoint.y);
+ await page.waitForTimeout(750);
+
+ const afterDrawing = await page.screenshot({
+ path: 'test-results/eeschema-draw-wires-02-after-drawing.png',
+ scale: 'device'
+ });
+
+ const diffRegion: DiffRegion = {
+ x: Math.max(0, Math.min(startPoint.x, endPoint.x) - 24),
+ y: Math.max(0, Math.min(startPoint.y, endPoint.y) - 24),
+ width: Math.abs(endPoint.x - startPoint.x) + 48,
+ height: Math.abs(endPoint.y - startPoint.y) + 48,
+ };
+
+ const drawingDiff = await compareScreenshots(page, afterToolClick, afterDrawing, diffRegion);
+
+ expect(drawingDiff.diffPixels).toBeGreaterThan(120);
+ expect(drawingDiff.diffRatio).toBeGreaterThan(0.01);
+ expect(drawingDiff.meanChannelDiff).toBeGreaterThan(1);
+
+ const realErrors = testLogger.errors
+ .slice(baselineErrorCount)
+ .filter((error) => !error.includes('favicon') && !error.includes('uncaught exception: unwind'));
+ expect(realErrors).toEqual([]);
+ });
+});
diff --git a/tests/package.json b/tests/package.json
index 28601d0..a58c04d 100644
--- a/tests/package.json
+++ b/tests/package.json
@@ -13,6 +13,12 @@
"test:kicad:chrome": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=chromium --headed",
"test:kicad": "npm run test:kicad:firefox",
"test:kicad:headed": "npm run test:kicad:chrome",
+ "test:pcbnew:firefox": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox kicad/pcbnew.spec.ts",
+ "test:pcbnew:chrome": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=chromium --headed kicad/pcbnew.spec.ts",
+ "test:eeschema:firefox": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox kicad/eeschema.spec.ts",
+ "test:eeschema:chrome": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=chromium --headed kicad/eeschema.spec.ts",
+ "test:eeschema": "npm run test:eeschema:firefox",
+ "test:eeschema:headed": "npm run test:eeschema:chrome",
"test:coroutine:firefox": "playwright test --config=playwright-coroutine.config.ts --project=firefox",
"test:coroutine:chrome": "playwright test --config=playwright-coroutine.config.ts --project=chromium --headed"
},
diff --git a/tests/scripts/setup-kicad-wasm.sh b/tests/scripts/setup-kicad-wasm.sh
index 1ffaba2..ab95af0 100755
--- a/tests/scripts/setup-kicad-wasm.sh
+++ b/tests/scripts/setup-kicad-wasm.sh
@@ -3,6 +3,8 @@
#
# Priority: Use local output/ directory (populated by docker/build.sh)
# Fallback: Copy from Docker volume directly
+#
+# Copies whichever editors are present (pcbnew, eeschema).
set -e
@@ -13,32 +15,46 @@ OUTPUT_DIR="$PROJECT_ROOT/output"
mkdir -p "$KICAD_TEST"
-# Check if output directory has the build files
-if [ -f "$OUTPUT_DIR/pcbnew.js" ] && [ -f "$OUTPUT_DIR/pcbnew.wasm" ]; then
- echo "Copying KiCad WASM files from output directory..."
- cp "$OUTPUT_DIR/pcbnew.js" "$KICAD_TEST/"
- cp "$OUTPUT_DIR/pcbnew.wasm" "$KICAD_TEST/"
- # Source map for debug symbols (optional)
- cp "$OUTPUT_DIR/pcbnew.wasm.map" "$KICAD_TEST/" 2>/dev/null || true
- # Worker file for pthreads (optional)
- cp "$OUTPUT_DIR/pcbnew.worker.js" "$KICAD_TEST/" 2>/dev/null || true
- # Bitmap resources for KiCad icons (optional)
- cp "$OUTPUT_DIR/images.tar.gz" "$KICAD_TEST/" 2>/dev/null || true
-else
- echo "Output directory not found, copying from Docker build..."
- docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
- kicad-wasm-builder:/workspace/build-wasm/kicad-pcbnew/pcbnew/pcbnew.js "$KICAD_TEST/"
- docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
- kicad-wasm-builder:/workspace/build-wasm/kicad-pcbnew/pcbnew/pcbnew.wasm "$KICAD_TEST/"
- # Source map for debug symbols (optional)
- docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
- kicad-wasm-builder:/workspace/build-wasm/kicad-pcbnew/pcbnew/pcbnew.wasm.map "$KICAD_TEST/" 2>/dev/null || true
- # Worker file for pthreads (optional)
- docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
- kicad-wasm-builder:/workspace/build-wasm/kicad-pcbnew/pcbnew/pcbnew.worker.js "$KICAD_TEST/" 2>/dev/null || true
- # Bitmap resources for KiCad icons (optional)
- docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
- kicad-wasm-builder:/workspace/build-wasm/kicad-pcbnew/resources/images.tar.gz "$KICAD_TEST/" 2>/dev/null || true
+# Copy one editor's artifacts (js, wasm, optional debug/map/worker). Returns 0
+# if the editor was present, 1 if neither output/ nor the docker volume has it.
+copy_app() {
+ local app="$1"
+
+ if [ -f "$OUTPUT_DIR/${app}.js" ] && [ -f "$OUTPUT_DIR/${app}.wasm" ]; then
+ echo "Copying ${app} WASM files from output directory..."
+ cp "$OUTPUT_DIR/${app}.js" "$KICAD_TEST/"
+ cp "$OUTPUT_DIR/${app}.wasm" "$KICAD_TEST/"
+ cp "$OUTPUT_DIR/${app}.wasm.map" "$KICAD_TEST/" 2>/dev/null || true
+ cp "$OUTPUT_DIR/${app}.worker.js" "$KICAD_TEST/" 2>/dev/null || true
+ cp "$OUTPUT_DIR/images.tar.gz" "$KICAD_TEST/" 2>/dev/null || true
+ return 0
+ fi
+
+ echo "Output ${app} not found locally, trying Docker volume..."
+ if docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
+ kicad-wasm-builder:/workspace/build-wasm/kicad-${app}/${app}/${app}.js "$KICAD_TEST/" 2>/dev/null \
+ && docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
+ kicad-wasm-builder:/workspace/build-wasm/kicad-${app}/${app}/${app}.wasm "$KICAD_TEST/" 2>/dev/null; then
+ docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
+ kicad-wasm-builder:/workspace/build-wasm/kicad-${app}/${app}/${app}.wasm.map "$KICAD_TEST/" 2>/dev/null || true
+ docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
+ kicad-wasm-builder:/workspace/build-wasm/kicad-${app}/${app}/${app}.worker.js "$KICAD_TEST/" 2>/dev/null || true
+ docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \
+ kicad-wasm-builder:/workspace/build-wasm/kicad-${app}/resources/images.tar.gz "$KICAD_TEST/" 2>/dev/null || true
+ return 0
+ fi
+
+ echo " (no ${app} artifacts found — skipping)"
+ return 1
+}
+
+found_any=0
+copy_app pcbnew && found_any=1
+copy_app eeschema && found_any=1
+
+if [ "$found_any" -eq 0 ]; then
+ echo "Error: neither pcbnew nor eeschema artifacts found in output/ or docker volume" >&2
+ exit 1
fi
# wxWidgets WASM JavaScript glue code (defines JS functions called from WASM)
diff --git a/wasm/cmake/Findngspice.cmake b/wasm/cmake/Findngspice.cmake
index 49c0123..a275d1a 100644
--- a/wasm/cmake/Findngspice.cmake
+++ b/wasm/cmake/Findngspice.cmake
@@ -3,14 +3,16 @@
# We provide stub values so CMake configuration succeeds
if(EMSCRIPTEN OR NOT KICAD_SPICE)
- message(STATUS "ngspice not available for WASM build (SPICE disabled)")
+ message(STATUS "ngspice not available for WASM build (using header stub)")
# Set variables to indicate ngspice is "found" but disabled
set(ngspice_FOUND TRUE)
set(NGSPICE_FOUND TRUE)
- # Provide empty values
- set(NGSPICE_INCLUDE_DIR "")
+ # Point at our header-only stub at wasm/stubs/ngspice/sharedspice.h so
+ # eeschema's sim/ngspice.{h,cpp} can compile. The library link line stays
+ # empty — the simulator frame is never instantiated in WASM.
+ set(NGSPICE_INCLUDE_DIR "${CMAKE_CURRENT_LIST_DIR}/../stubs")
set(NGSPICE_LIBRARY "")
set(NGSPICE_LIBRARIES "")
diff --git a/wasm/stubs/char_traits_uint16_workaround.h b/wasm/stubs/char_traits_uint16_workaround.h
new file mode 100644
index 0000000..2483938
--- /dev/null
+++ b/wasm/stubs/char_traits_uint16_workaround.h
@@ -0,0 +1,111 @@
+/*
+ * libc++ workaround: provide std::char_traits<unsigned short> for WASM builds.
+ *
+ * KiCad's third-party Altium parser uses
+ * typedef std::basic_string<uint16_t> utf16string;
+ * (kicad/thirdparty/compoundfilereader/compoundfilereader.h:264).
+ *
+ * Modern libc++ (the version bundled with current Emscripten) pulls
+ * <__format/parser_std_format_spec.h> via <vector>, which triggers implicit
+ * instantiation of char_traits<unsigned short>. The standard only specializes
+ * char_traits for char / wchar_t / char8_t / char16_t / char32_t, so the
+ * uint16_t (== unsigned short) usage now fails to compile.
+ *
+ * We force-include this header into every translation unit via the build
+ * script's CMAKE_CXX_FLAGS so the specialization is visible before any code
+ * that needs it. Specializing std::char_traits for non-standard types is
+ * technically undefined per the standard but is the established workaround
+ * historically supported by libc++/libstdc++.
+ */
+
+#ifndef KICAD_WASM_CHAR_TRAITS_UINT16_WORKAROUND_H
+#define KICAD_WASM_CHAR_TRAITS_UINT16_WORKAROUND_H
+
+#ifdef __cplusplus
+#ifdef __EMSCRIPTEN__
+
+#include <cstddef>
+#include <cstring>
+#include <cwchar>
+#include <ios>
+
+namespace std {
+
+template<>
+struct char_traits<unsigned short>
+{
+ using char_type = unsigned short;
+ using int_type = int;
+ using off_type = streamoff;
+ using pos_type = fpos<mbstate_t>;
+ using state_type = mbstate_t;
+
+ static constexpr void assign( char_type& a, const char_type& b ) noexcept { a = b; }
+ static constexpr bool eq( char_type a, char_type b ) noexcept { return a == b; }
+ static constexpr bool lt( char_type a, char_type b ) noexcept { return a < b; }
+
+ static int compare( const char_type* s1, const char_type* s2, size_t n )
+ {
+ for( size_t i = 0; i < n; ++i )
+ {
+ if( s1[i] < s2[i] ) return -1;
+ if( s1[i] > s2[i] ) return 1;
+ }
+ return 0;
+ }
+
+ static size_t length( const char_type* s )
+ {
+ size_t i = 0;
+ while( s[i] != 0 ) ++i;
+ return i;
+ }
+
+ static const char_type* find( const char_type* s, size_t n, const char_type& a )
+ {
+ for( size_t i = 0; i < n; ++i )
+ if( s[i] == a ) return s + i;
+ return nullptr;
+ }
+
+ static char_type* move( char_type* s1, const char_type* s2, size_t n )
+ {
+ return static_cast<char_type*>( memmove( s1, s2, n * sizeof( char_type ) ) );
+ }
+
+ static char_type* copy( char_type* s1, const char_type* s2, size_t n )
+ {
+ return static_cast<char_type*>( memcpy( s1, s2, n * sizeof( char_type ) ) );
+ }
+
+ static char_type* assign( char_type* s, size_t n, char_type a )
+ {
+ for( size_t i = 0; i < n; ++i ) s[i] = a;
+ return s;
+ }
+
+ static constexpr int_type not_eof( int_type c ) noexcept
+ {
+ return c == eof() ? static_cast<int_type>( 0 ) : c;
+ }
+
+ static constexpr char_type to_char_type( int_type c ) noexcept
+ {
+ return static_cast<char_type>( c );
+ }
+
+ static constexpr int_type to_int_type( char_type c ) noexcept
+ {
+ return static_cast<int_type>( c );
+ }
+
+ static constexpr bool eq_int_type( int_type a, int_type b ) noexcept { return a == b; }
+ static constexpr int_type eof() noexcept { return static_cast<int_type>( -1 ); }
+};
+
+} // namespace std
+
+#endif // __EMSCRIPTEN__
+#endif // __cplusplus
+
+#endif // KICAD_WASM_CHAR_TRAITS_UINT16_WORKAROUND_H
diff --git a/wasm/stubs/eeschema_frame_stub.cpp b/wasm/stubs/eeschema_frame_stub.cpp
new file mode 100644
index 0000000..1683f99
--- /dev/null
+++ b/wasm/stubs/eeschema_frame_stub.cpp
@@ -0,0 +1,15 @@
+/*
+ * Eeschema frame stubs for KiCad WASM build.
+ *
+ * Mirror of pcb_frame_stub.cpp. Populate as linker errors surface during the
+ * first eeschema-wasm build. Methods that need to be stubbed are typically:
+ * - Scripting helpers (LoadSchematic / SaveSchematic) when KICAD_SCRIPTING=OFF
+ * - Action-plugin glue (no plugins in WASM)
+ * - Filesystem-watcher hooks when wxUSE_FSWATCHER=0
+ *
+ * Leave this file empty until the linker complains; the build script skips
+ * compiling it when it has zero bytes.
+ */
+
+#ifdef __EMSCRIPTEN__
+#endif
diff --git a/wasm/stubs/eeschema_ngspice_data_stubs.cpp b/wasm/stubs/eeschema_ngspice_data_stubs.cpp
new file mode 100644
index 0000000..a8e5c7c
--- /dev/null
+++ b/wasm/stubs/eeschema_ngspice_data_stubs.cpp
@@ -0,0 +1,28 @@
+/*
+ * Empty replacements for the four largest ngspice model data initializers.
+ *
+ * Each of sim_model_ngspice_data_{bsim4,b3soi,b4soi,hsim}.cpp defines a
+ * single function (addBSIM4/addB3SOI/addB4SOI/addHSIM) that pushes hundreds
+ * of entries into NGSPICE_MODEL_INFO_MAP::modelInfos[...]. Once compiled to
+ * WASM these functions exceed the V8/SpiderMonkey limit on locals per
+ * function ("too many locals"), so Firefox refuses to instantiate the
+ * resulting module.
+ *
+ * The simulator UI is never reachable in the WASM build (FRAME_SIMULATOR
+ * fails to instantiate via the ngspice header stub at
+ * wasm/stubs/ngspice/sharedspice.h), so leaving these tables empty is safe.
+ *
+ * eeschema/CMakeLists.txt excludes the original four sources from
+ * EESCHEMA_SIM_SRCS for EMSCRIPTEN and adds this file instead.
+ */
+
+#ifdef __EMSCRIPTEN__
+
+#include <sim/sim_model_ngspice.h>
+
+void NGSPICE_MODEL_INFO_MAP::addBSIM4() {}
+void NGSPICE_MODEL_INFO_MAP::addB3SOI() {}
+void NGSPICE_MODEL_INFO_MAP::addB4SOI() {}
+void NGSPICE_MODEL_INFO_MAP::addHSIM() {}
+
+#endif // __EMSCRIPTEN__
diff --git a/wasm/stubs/ngspice/sharedspice.h b/wasm/stubs/ngspice/sharedspice.h
new file mode 100644
index 0000000..5002383
--- /dev/null
+++ b/wasm/stubs/ngspice/sharedspice.h
@@ -0,0 +1,55 @@
+/*
+ * Minimal stub of ngspice's sharedspice.h for KiCad WASM builds.
+ *
+ * Only the type names referenced by kicad/eeschema/sim/ngspice.{h,cpp} need
+ * to exist. The eeschema sim layer compiles but the simulator frame is never
+ * instantiated in WASM (FRAME_SIMULATOR's try/catch in IFACE::CreateKiWindow
+ * catches the init failure and returns nullptr).
+ *
+ * We intentionally do NOT define NGSPICE_PACKAGE_VERSION so that ngspice.h's
+ * fallback `typedef bool NG_BOOL;` (line 46) provides the boolean type.
+ */
+
+#ifndef KICAD_WASM_NGSPICE_SHAREDSPICE_STUB_H
+#define KICAD_WASM_NGSPICE_SHAREDSPICE_STUB_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct ngcomplex {
+ double cx_real;
+ double cx_imag;
+} ngcomplex_t;
+
+struct vector_info {
+ char* v_name;
+ int v_type;
+ short v_flags;
+ double* v_realdata;
+ ngcomplex_t* v_compdata;
+ int v_length;
+};
+
+typedef struct vector_info* pvector_info;
+
+/* Opaque payload types for callbacks we never wire up (SendData/SendInitData). */
+typedef struct vecvaluesall* pvecvaluesall;
+typedef struct vecinfoall* pvecinfoall;
+
+/*
+ * Function types (not pointers). ngspice.h references them as `SendChar*` etc.,
+ * so the trailing star in the typedef site makes the pointer.
+ */
+typedef int (SendChar)(char*, int, void*);
+typedef int (SendStat)(char*, int, void*);
+typedef int (ControlledExit)(int, bool, bool, int, void*);
+typedef int (SendData)(pvecvaluesall, int, int, void*);
+typedef int (SendInitData)(pvecinfoall, int, void*);
+typedef int (BGThreadRunning)(bool, int, void*);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* KICAD_WASM_NGSPICE_SHAREDSPICE_STUB_H */