diff --git a/docker/build-calculator.sh b/docker/build-calculator.sh new file mode 100755 index 0000000..4c549f7 --- /dev/null +++ b/docker/build-calculator.sh @@ -0,0 +1,90 @@ +#!/bin/bash +# Build KiCad PCB Calculator WASM inside Docker, then apply asyncify on host. +# Mirrors docker/build.sh; differences: +# - calls scripts/kicad/build-calculator.sh inside the container +# - copies pcb_calculator.* from build-wasm/kicad-calculator and renames to +# calculator.* on the host (keeps the kicad-fork patch minimal) +# - runs host-side passes against output/calculator.{js,wasm} + +# Redirect all output to a log file (re-execs script with redirection) +source "$(dirname "$0")/../scripts/common/logging.sh" + +set -e + +cd "$(dirname "$0")/.." + +# 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}" + +# Add -j 10 by default if no -j flag is given +ARGS=("$@") +if [[ ! " ${ARGS[*]} " =~ " -j " ]]; then + ARGS+=("-j" "10") +fi + +# Start container if not running +docker compose -f docker/docker-compose.yml up -d + +# Sync source code to container volume (same flake-tolerant retry as build.sh). +echo "Syncing source code to container..." +sync_rc=0 +for sync_attempt in 1 2 3; do + if docker compose -f docker/docker-compose.yml exec kicad-wasm-builder \ + rsync -r --delete --checksum --inplace \ + --exclude="build-wasm" \ + --exclude="output" \ + --exclude=".git" \ + --exclude="logs" \ + --exclude=".idea" \ + --exclude="node_modules" \ + --exclude="tools/emsdk" \ + /workspace-host/ /workspace/ + then + sync_rc=0 + else + sync_rc=$? + fi + { [ $sync_rc -eq 0 ] || [ $sync_rc -eq 24 ]; } && break + echo "rsync attempt ${sync_attempt} failed (exit ${sync_rc}); retrying in 2s..." + sleep 2 +done +if [ $sync_rc -ne 0 ] && [ $sync_rc -ne 24 ]; then + echo "ERROR: source sync failed after retries (exit ${sync_rc})"; exit 1 +fi + +# Run build inside container. +docker compose -f docker/docker-compose.yml exec -e EMSDK=/emsdk kicad-wasm-builder \ + /workspace/scripts/kicad/build-calculator.sh "${ARGS[@]}" + +# Copy outputs. Artifacts are named calculator.* directly thanks to the +# OUTPUT_NAME calculator property we set on the pcb_calculator target for +# EMSCRIPTEN builds (kicad/pcb_calculator/CMakeLists.txt). +echo "Copying build output to ./output/..." +docker compose -f docker/docker-compose.yml exec kicad-wasm-builder \ + bash -c ' + set -e + mkdir -p /workspace/output + SRC=/workspace/build-wasm/kicad-calculator/pcb_calculator + cp "$SRC/calculator.js" /workspace/output/calculator.js + cp "$SRC/calculator.wasm" /workspace/output/calculator.wasm + [ -f "$SRC/calculator.worker.js" ] && cp "$SRC/calculator.worker.js" /workspace/output/calculator.worker.js || true + [ -f "$SRC/calculator.wasm.map" ] && cp "$SRC/calculator.wasm.map" /workspace/output/calculator.wasm.map || true + [ -f "$SRC/calculator.wasm.debug.wasm" ] && cp "$SRC/calculator.wasm.debug.wasm" /workspace/output/calculator.wasm.debug.wasm || true + cp /workspace/build-wasm/kicad-calculator/resources/images.tar.gz /workspace/output/images.tar.gz 2>/dev/null || true + cp /workspace/build-wasm/wxwidgets/build/wasm/wx.js /workspace/output/wx.js 2>/dev/null || true + ' + +# Inject dynCall shims into the JS loader (fixes Emscripten 4.x dynCall_* errors). +./scripts/common/inject-dyncall-shims.sh output/calculator.js + +# Run wasm-emscripten-finalize on host (memory-intensive, skipped inside Docker). +./scripts/common/apply-finalize.sh output/calculator.wasm output/calculator.wasm + +# Run wasm-opt --asyncify on host (~20-30GB RAM). +./scripts/common/apply-asyncify.sh output/calculator.wasm output/calculator.wasm + +echo "" +echo "Build complete. Output files in ./output/" +ls -lh output/ diff --git a/docker/build.sh b/docker/build.sh index 290112a..d466557 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -33,20 +33,40 @@ docker compose -f docker/docker-compose.yml up -d # This avoids the timestamp mismatch cycle that caused full rebuilds every time. # Transferred files get current container time, so make detects them correctly. echo "Syncing source code to container..." -# rsync exit code 24 = "some files vanished before transfer" (harmless race condition) -docker compose -f docker/docker-compose.yml exec kicad-wasm-builder \ - rsync -r --delete --checksum \ - --exclude="build-wasm" \ - --exclude="output" \ - --exclude=".git" \ - --exclude="logs" \ - --exclude=".idea" \ - --exclude="node_modules" \ - --exclude="tools/emsdk" \ - /workspace-host/ /workspace/ || [ $? -eq 24 ] +# rsync into the macOS-backed volume intermittently hits transient VirtioFS glitches: +# temp-file rename failures (exit 23) or vanished-source files (exit 24, harmless). +# --inplace avoids the temp-file+rename pattern that triggers exit 23; retry up to 3x +# for any residual flakiness (--checksum makes each retry skip already-synced files). +sync_rc=0 +for sync_attempt in 1 2 3; do + if docker compose -f docker/docker-compose.yml exec kicad-wasm-builder \ + rsync -r --delete --checksum --inplace \ + --exclude="build-wasm" \ + --exclude="output" \ + --exclude=".git" \ + --exclude="logs" \ + --exclude=".idea" \ + --exclude="node_modules" \ + --exclude="tools/emsdk" \ + /workspace-host/ /workspace/ + then + sync_rc=0 + else + sync_rc=$? + fi + { [ $sync_rc -eq 0 ] || [ $sync_rc -eq 24 ]; } && break + echo "rsync attempt ${sync_attempt} failed (exit ${sync_rc}); retrying in 2s..." + sleep 2 +done +if [ $sync_rc -ne 0 ] && [ $sync_rc -ne 24 ]; then + echo "ERROR: source sync failed after retries (exit ${sync_rc})"; exit 1 +fi # Run build command (without asyncify - handled on host due to memory requirements) -docker compose -f docker/docker-compose.yml exec kicad-wasm-builder \ +# -e EMSDK=/emsdk: `docker compose exec` bypasses the entrypoint that sources +# 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 kicad-wasm-builder \ /workspace/scripts/kicad/build-pcbnew.sh "${ARGS[@]}" # Copy output to host-accessible directory diff --git a/kicad b/kicad index 6efc02a..0cc6362 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit 6efc02aacfc876323e0bef14425fb7c7ae56eea5 +Subproject commit 0cc6362377080b06e7ef662ba25f1e8ccccfd8bc diff --git a/scripts/kicad/build-calculator.sh b/scripts/kicad/build-calculator.sh new file mode 100755 index 0000000..1f15996 --- /dev/null +++ b/scripts/kicad/build-calculator.sh @@ -0,0 +1,305 @@ +#!/bin/bash +# Build KiCad PCB Calculator for WebAssembly. +# This builds the standalone pcb_calculator app as a single WASM binary. +# +# Mirrors scripts/kicad/build-pcbnew.sh almost verbatim; differences: +# - separate build tree: build-wasm/kicad-calculator +# - no pcbnew_scripting_stub (calculator doesn't link scripting) +# - embind compiles wasm/bindings/calculator_embind.cpp +# - final make target is `pcb_calculator` (single binary, no kiface MODULE, +# enabled by the EMSCRIPTEN branch we added to pcb_calculator/CMakeLists.txt) +# +# Usage: +# ./scripts/kicad/build-calculator.sh [options] +# +# Options (same as build-pcbnew.sh): +# --full Full clean rebuild (dependencies + KiCad) +# --clean-kicad Clean only KiCad-calculator 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) + +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-calculator" +KICAD_STAMP="${BUILD_ROOT}/stamps/kicad-calculator.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 (same as build-pcbnew.sh) +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 Calculator 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 (shared sysroot with pcbnew) +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 + +# Step 4: Build wxWidgets (shared with pcbnew) +log_info "Building wxWidgets..." +"${SCRIPT_DIR}/../build-wxuniversal-wasm.sh" --no-clean + +log_info "Building KiCad PCB Calculator ${KICAD_VERSION} for WASM..." + +# Step 5: Set build type (same flags as pcbnew) +if [ "${DEBUG_BUILD:-0}" = "1" ] || [ $DEBUG -eq 1 ]; then + BUILD_TYPE="Debug" + EXTRA_FLAGS="-g -O1 -fexceptions -matomics -mbulk-memory" + LINKER_DEBUG_FLAGS="-O1 -g -gseparate-dwarf -fexceptions" + log_info "Building Calculator in DEBUG mode (separate DWARF for smaller main binary)" +else + BUILD_TYPE="Release" + EXTRA_FLAGS="-O2 -fexceptions -matomics -mbulk-memory" + LINKER_DEBUG_FLAGS="-O0 -fexceptions" + log_info "Building Calculator 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 the calculator links against. +# pcbnew_scripting_stub is omitted — calculator doesn't link scripting. +STUBS_DIR="${PROJECT_ROOT}/wasm/stubs" +STUBS_BUILD="${BUILD_ROOT}/stubs" +mkdir -p "${STUBS_BUILD}" + +log_info "Building stub libraries..." +# libgit2 stub: single_top.cpp calls git_libgit2_init / git_libgit2_shutdown. +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" + +# curl stub: referenced by common/. +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" + +# NNG stub: required because KICAD_IPC_API=ON. 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: Swap EMSDK wasm-opt for stub (real one runs on host post-build). +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 +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: Swap wasm-emscripten-finalize for stub (real one runs on 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 +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 +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 with CMake +log_info "Configuring KiCad Calculator with CMake..." + +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}/libnng_stub.a ${STUBS_BUILD}/calculator_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). +EMBIND_SRC="${PROJECT_ROOT}/wasm/bindings/calculator_embind.cpp" +if [ -f "$EMBIND_SRC" ]; then + log_info "Compiling Embind bindings..." + 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") + KICAD_INCLUDES="-I${KICAD_BUILD} -I${KICAD_DIR}/include -I${KICAD_DIR}/pcb_calculator -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" + em++ -std=c++20 -c ${EXTRA_FLAGS} ${WX_CXXFLAGS} ${KICAD_INCLUDES} "$EMBIND_SRC" -o "${STUBS_BUILD}/calculator_embind.o" +fi + +# Step 8: Build pcb_calculator target (single-binary WASM executable) +log_info "Building pcb_calculator..." +emmake make -j${JOBS} pcb_calculator + +# Step 8.1: Build bitmap resources (images.tar.gz) +log_info "Building bitmap resources..." +emmake make bitmap_archive_build + +# Step 9: Create stamp file +create_stamp "${KICAD_STAMP}" +log_info "KiCad Calculator build complete!" +log_info "Output: ${KICAD_BUILD}/pcb_calculator/pcb_calculator.js" diff --git a/tests/apps/kicad/calculator.html b/tests/apps/kicad/calculator.html new file mode 100644 index 0000000..ab970cc --- /dev/null +++ b/tests/apps/kicad/calculator.html @@ -0,0 +1,198 @@ + + + + + + KiCad Calculator WASM + + + +
+ +
+
Initializing...
+
+
+ +
+ + + + + + + + diff --git a/tests/kicad/calculator.spec.ts b/tests/kicad/calculator.spec.ts new file mode 100644 index 0000000..bf134b4 --- /dev/null +++ b/tests/kicad/calculator.spec.ts @@ -0,0 +1,150 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from './fixtures'; +import { clickByLabel, clickTreeItem, findAllTreeItems } from '../e2e/utils/element-tracker'; + +/** + * PCB Calculator WASM E2E Tests + * + * The calculator is a wxFrame containing a wxTreebook with ~14 calculator + * panels grouped under four section pages. There's no GAL canvas, no + * toolbars-as-tested, and no setup wizard for the calculator itself. + * + * However, KiCad's first-run setup wizard pops up before *any* app's frame + * (same wizard pcbnew sees) because Emscripten's MEMFS starts empty each + * page load and KiCad finds no config. We click through it the same way + * pcbnew.spec.ts's completeWizard() does, then verify: + * 1. The calculator frame loads and registers its panels. + * 2. The treebook contains the panel labels we expect. + * 3. Clicking a leaf panel ("Color Code") switches the active page. + * + * Panel labels are sourced from pcb_calculator_frame.cpp:170-192 (kicad fork). + */ + +async function waitForRegistry(page: Page): Promise { + await page.waitForFunction(() => !!(window as any).wxElementRegistry, null, { timeout: 90000 }); + // Give the C++ side a moment to register every panel after the frame + // first appears — the treebook is populated synchronously in the frame + // ctor, but wxElementRegistry registrations are flushed on idle. + await page.waitForTimeout(2000); +} + +/** + * Click through KiCad's first-run setup wizard. Mirrors the intent of + * tests/kicad/pcbnew.spec.ts's completeWizard() but waits actively for each + * "Next >" / "Finish" button to appear in wxElementRegistry before clicking + * — the calculator boots quickly enough that the wizard buttons can lag + * behind by a second or two, and a fixed sleep proved flaky. + */ +async function waitForLabel(page: Page, label: string, timeoutMs: number): Promise { + try { + await page.waitForFunction( + (l) => { + const r = (window as any).wxElementRegistry; + return !!(r && r.findByLabel && r.findByLabel(l, {}).length > 0); + }, + label, + { timeout: timeoutMs } + ); + return true; + } catch { + return false; + } +} + +async function completeFirstRunWizard(page: Page): Promise { + // The canvas becomes visible only after Module.onRuntimeInitialized fires, + // which is a reliable witness that the WASM has booted. + await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 }); + await waitForRegistry(page); + + for (let i = 1; i <= 12; i++) { + const haveNext = await waitForLabel(page, 'Next >', 15000); + if (haveNext) { + const clickedNext = await clickByLabel(page, 'Next >'); + if (clickedNext) { + await page.waitForTimeout(400); + continue; + } + } + const haveFinish = await waitForLabel(page, 'Finish', 5000); + if (haveFinish) { + await clickByLabel(page, 'Finish'); + await page.waitForTimeout(400); + } + break; + } + + // Allow the wizard to dismiss and the calculator frame to register. + await page.waitForTimeout(2500); +} + +async function getRegistryLabels(page: Page): Promise { + return await page.evaluate(() => { + const registry = (window as any).wxElementRegistry; + if (!registry || !registry.findAll) return []; + const all = registry.findAll({}); + return all + .map((el: any) => (el && el.label ? String(el.label) : '')) + .filter((l: string) => l.length > 0); + }); +} + +test.describe('PCB Calculator WASM', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/kicad/calculator.html'); + }); + + test('loads calculator frame', async ({ page, testLogger }) => { + void testLogger; + await completeFirstRunWizard(page); + + // The default panel (Regulator) renders its controls into the registry + // as soon as the frame is up. "Calculate" is a unique button label on + // panel_regulator and a reliable witness that the calculator is live. + const labels = await getRegistryLabels(page); + const hasRegulatorPanel = labels.some(l => l === 'Calculate'); + expect(hasRegulatorPanel, `expected the calculator's default Regulator panel to be live (Calculate button registered). Got: ${JSON.stringify(labels.slice(0, 30))}`).toBe(true); + + await page.screenshot({ path: 'test-results/calculator-loaded.png', scale: 'device' }); + }); + + test('treebook lists expected panels', async ({ page, testLogger }) => { + void testLogger; + await completeFirstRunWizard(page); + + const treeItems = await findAllTreeItems(page); + const treeLabels = treeItems.map(i => i.label).filter((l): l is string => typeof l === 'string'); + + const requiredSubset = [ + 'Regulators', + 'Resistor Calculator', + 'Via Size', + 'Track Width', + 'Color Code', + 'RF Attenuators', + 'Transmission Lines', + ]; + const missing = requiredSubset.filter(req => !treeLabels.includes(req)); + expect(missing, `treebook is missing expected panels: ${JSON.stringify(missing)} (tree items: ${JSON.stringify(treeLabels)})`).toEqual([]); + }); + + test('switch to Color Code panel', async ({ page, testLogger }) => { + void testLogger; + await completeFirstRunWizard(page); + + await page.screenshot({ path: 'test-results/calculator-before-switch.png', scale: 'device' }); + + const clicked = await clickTreeItem(page, 'Color Code'); + expect(clicked, 'expected to find and click the Color Code tree item').toBe(true); + + // Allow the panel to swap in. The Color Code panel exposes a unique + // "Tolerance" label that the Regulator panel does not — use it as a + // proof-of-switch. + await page.waitForTimeout(800); + const labelsAfter = await getRegistryLabels(page); + const onColorCodePanel = labelsAfter.some(l => /Tolerance/i.test(l)); + expect(onColorCodePanel, `expected Color Code panel to be active after click; labels: ${JSON.stringify(labelsAfter.slice(0, 40))}`).toBe(true); + + await page.screenshot({ path: 'test-results/calculator-color-code.png', scale: 'device' }); + }); +}); diff --git a/tests/package.json b/tests/package.json index 28601d0..68f6e55 100644 --- a/tests/package.json +++ b/tests/package.json @@ -13,6 +13,11 @@ "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", + "setup:calculator": "./scripts/setup-calculator-wasm.sh", + "test:calculator:firefox": "npm run setup:calculator && playwright test --config=playwright-calculator.config.ts --project=firefox", + "test:calculator:chrome": "npm run setup:calculator && playwright test --config=playwright-calculator.config.ts --project=chromium --headed", + "test:calculator": "npm run test:calculator:firefox", + "test:calculator:headed": "npm run test:calculator: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/playwright-calculator.config.ts b/tests/playwright-calculator.config.ts new file mode 100644 index 0000000..911dabb --- /dev/null +++ b/tests/playwright-calculator.config.ts @@ -0,0 +1,90 @@ +import { defineConfig, devices } from '@playwright/test'; +import { execSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +// Mirrors playwright-kicad.config.ts. The only structural difference is +// testMatch — calculator.spec.ts lives next to pcbnew.spec.ts under ./kicad, +// and we filter to it so running `npm run test:calculator` doesn't drag in +// the pcbnew suite. + +const PORT_FILE = path.join(__dirname, '.test-port'); + +// NOTE: Chrome headless crashes on ARM Mac due to SwiftShader WebGL bug +// (Chromium issues #1416283, #338414704). Firefox headless works reliably. +// Use --project=firefox for headless, --project=chromium for headed debugging. + +function getOrFindPort(): number { + try { + const stat = fs.statSync(PORT_FILE); + const age = Date.now() - stat.mtimeMs; + if (age < 60000) { + const port = parseInt(fs.readFileSync(PORT_FILE, 'utf-8').trim()); + if (port > 0 && port < 65536) { + return port; + } + } + } catch { + // File doesn't exist or can't be read + } + + const port = findFreePort(); + fs.writeFileSync(PORT_FILE, port.toString()); + return port; +} + +function findFreePort(): number { + try { + const result = execSync( + 'python3 -c "import socket; s=socket.socket(); s.bind((\'\',0)); print(s.getsockname()[1]); s.close()"', + { encoding: 'utf-8' } + ); + return parseInt(result.trim()); + } catch { + return 9000 + Math.floor(Math.random() * 1000); + } +} + +const port = getOrFindPort(); + +export default defineConfig({ + globalSetup: './global-setup.ts', + testDir: './kicad', + testMatch: 'calculator.spec.ts', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + timeout: 180000, // KiCad WASM needs more time to load (3 minutes) + + use: { + baseURL: `http://localhost:${port}`, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + + projects: [ + { + name: 'firefox', + use: { + ...devices['Desktop Firefox'], + viewport: { width: 1280, height: 720 }, + }, + }, + { + // Uses system Chrome (not bundled Chromium) so WebGL runs on the real GPU. + name: 'chromium', + use: { + channel: 'chrome', + viewport: { width: 1280, height: 720 }, + }, + }, + ], + + webServer: { + command: `npx serve apps -p ${port} -c ../serve.json`, + port: port, + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/tests/scripts/setup-calculator-wasm.sh b/tests/scripts/setup-calculator-wasm.sh new file mode 100755 index 0000000..2ca30d8 --- /dev/null +++ b/tests/scripts/setup-calculator-wasm.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Copy PCB Calculator WASM build output to the test directory. +# Mirrors setup-kicad-wasm.sh; differences: +# - copies calculator.{js,wasm,wasm.map,worker.js} instead of pcbnew.* +# +# Priority: use local output/ (populated by docker/build-calculator.sh). +# Fallback: copy from the Docker volume directly. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +KICAD_TEST="$PROJECT_ROOT/tests/apps/kicad" +OUTPUT_DIR="$PROJECT_ROOT/output" + +mkdir -p "$KICAD_TEST" + +if [ -f "$OUTPUT_DIR/calculator.js" ] && [ -f "$OUTPUT_DIR/calculator.wasm" ]; then + echo "Copying Calculator WASM files from output directory..." + cp "$OUTPUT_DIR/calculator.js" "$KICAD_TEST/" + cp "$OUTPUT_DIR/calculator.wasm" "$KICAD_TEST/" + # Source map for debug symbols (optional) + cp "$OUTPUT_DIR/calculator.wasm.map" "$KICAD_TEST/" 2>/dev/null || true + # Worker file for pthreads (optional) + cp "$OUTPUT_DIR/calculator.worker.js" "$KICAD_TEST/" 2>/dev/null || true + # Bitmap resources for KiCad icons (shared with pcbnew; optional) + cp "$OUTPUT_DIR/images.tar.gz" "$KICAD_TEST/" 2>/dev/null || true +else + echo "Output directory not found, copying from Docker build..." + SRC=/workspace/build-wasm/kicad-calculator/pcb_calculator + docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \ + "kicad-wasm-builder:${SRC}/pcb_calculator.js" "$KICAD_TEST/calculator.js" + docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \ + "kicad-wasm-builder:${SRC}/pcb_calculator.wasm" "$KICAD_TEST/calculator.wasm" + # Optional artifacts (best-effort). + docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \ + "kicad-wasm-builder:${SRC}/pcb_calculator.wasm.map" "$KICAD_TEST/calculator.wasm.map" 2>/dev/null || true + docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \ + "kicad-wasm-builder:${SRC}/pcb_calculator.worker.js" "$KICAD_TEST/calculator.worker.js" 2>/dev/null || true + docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \ + kicad-wasm-builder:/workspace/build-wasm/kicad-calculator/resources/images.tar.gz "$KICAD_TEST/" 2>/dev/null || true +fi + +# wxWidgets WASM glue code (shared with pcbnew). +echo "Copying wxWidgets WASM glue code..." +if [ -f "$OUTPUT_DIR/wx.js" ]; then + cp "$OUTPUT_DIR/wx.js" "$KICAD_TEST/" +else + if docker compose -f "$PROJECT_ROOT/docker/docker-compose.yml" cp \ + kicad-wasm-builder:/workspace/build-wasm/wxwidgets/build/wasm/wx.js "$KICAD_TEST/" 2>/dev/null; then + : + else + cp "$PROJECT_ROOT/wxwidgets/build/wasm/wx.js" "$KICAD_TEST/" + fi +fi + +echo "Calculator WASM files copied to $KICAD_TEST" +ls -lh "$KICAD_TEST" diff --git a/wasm/bindings/calculator_embind.cpp b/wasm/bindings/calculator_embind.cpp new file mode 100644 index 0000000..9810154 --- /dev/null +++ b/wasm/bindings/calculator_embind.cpp @@ -0,0 +1,18 @@ +/* + * Embind bindings for KiCad PCB Calculator WASM. + * + * Currently empty — reserved for future calculator-specific JS bindings + * (e.g., exposing transmission-line calculators or attenuator math to + * Pyodide / browser callers). Kept as a separate translation unit so the + * calculator build pipeline mirrors pcbnew's pcbnew_embind.cpp structure. + */ + +#ifdef __EMSCRIPTEN__ +#include + +using namespace emscripten; + +EMSCRIPTEN_BINDINGS(pcb_calculator) { + // Reserved for future calculator-specific JS bindings. +} +#endif diff --git a/wxwidgets b/wxwidgets index d1d1627..6fb2eac 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit d1d1627b279672fc71deb4ff4512a7771dfc2cc8 +Subproject commit 6fb2eac2572cf0d3964ba8bec8d73e017d311733