diff --git a/docker/build.sh b/docker/build.sh index af1fc1d..e4281fc 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -81,7 +81,7 @@ trap 'kw_fail 130; exit 130' INT TERM cd "$(dirname "$0")/.." -VALID_APPS="kicad_editor | pcbnew | eeschema | calculator | pl_editor | gerbview | sym_convert | occ_service | all" +VALID_APPS="kicad_editor | pcbnew | eeschema | calculator | pl_editor | gerbview | kicad_tools | occ_service | all" usage() { echo "Usage: ./docker/build.sh [,...] [args...]" >&2 @@ -114,7 +114,7 @@ else IFS=',' read -r -a APPS <<< "$APP_NAME" for app in "${APPS[@]}"; do case "$app" in - kicad_editor|pcbnew|eeschema|calculator|pl_editor|gerbview|sym_convert|occ_service) ;; + kicad_editor|pcbnew|eeschema|calculator|pl_editor|gerbview|kicad_tools|occ_service) ;; *) echo "Error: unknown app '$app' (expected: ${VALID_APPS})" >&2 usage @@ -213,7 +213,6 @@ kicad_subdir_for() { case "$1" in calculator) echo "pcb_calculator" ;; pl_editor) echo "pagelayout_editor" ;; - sym_convert) echo "eeschema" ;; *) echo "$1" ;; esac } @@ -239,7 +238,7 @@ compile_app() { # EMSDK lets scripts/common/env.sh source /emsdk/emsdk_env.sh and activate the toolchain. # BUILD_3D_VIEWER passes through EMPTY when the host didn't set it, so # build-kicad-target.sh can apply per-app defaults (ON for editors, OFF - # for headless CLIs like sym_convert — the gl1 shim needs glm). + # for headless CLIs like kicad_tools — the gl1 shim needs glm). docker compose -f docker/docker-compose.yml exec -e EMSDK=/emsdk \ -e BUILD_3D_VIEWER="${BUILD_3D_VIEWER:-}" \ kicad-wasm-builder \ @@ -273,10 +272,10 @@ postprocess_app() { local app="$1" local out_dir="output" - # The converter and the OCC service are finalized in-container (real tools, - # small -g0 wasm) and build with ASYNCIFY=0, so they need no host + # The headless CLI and the OCC service are finalized in-container (real + # tools, small -g0 wasm) and build with ASYNCIFY=0, so they need no host # post-processing (no dyncall shims, no finalize, no asyncify). - if [ "$app" = "sym_convert" ] || [ "$app" = "occ_service" ]; then + if [ "$app" = "kicad_tools" ] || [ "$app" = "occ_service" ]; then echo "Skipping host post-processing for ${app} (finalized in-container)" return 0 fi @@ -296,14 +295,12 @@ postprocess_app() { kw_stage finalize ./scripts/common/apply-finalize.sh "${out_dir}/${app}.wasm" "${out_dir}/${app}.wasm" - # Apply asyncify transformation on host. The converter is a synchronous node - # CLI built with ASYNCIFY=0, so asyncify is unnecessary and would be wrong. + # Apply asyncify transformation on host (the ASYNCIFY=0 CLIs returned + # early above and never reach this). # apply-asyncify always runs the --hoist-cpp-catches pass FIRST (native wasm-EH is the only build # mode) so Asyncify can suspend from inside C++ catch arms, then asyncify + removelist + wasm-opt -O1. - if [ "$app" != "sym_convert" ]; then - kw_stage asyncify - ./scripts/common/apply-asyncify.sh "${out_dir}/${app}.wasm" "${out_dir}/${app}.wasm" - fi + kw_stage asyncify + ./scripts/common/apply-asyncify.sh "${out_dir}/${app}.wasm" "${out_dir}/${app}.wasm" } # --- Pipelined driver state (KICAD_PIPELINE=1) --- diff --git a/kicad b/kicad index ca19e5d..acaf472 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit ca19e5d6c85688937e08bcd4989b8c93e07be382 +Subproject commit acaf47234afe2e93400b23a93b6401b28d9932db diff --git a/scripts/kicad/build-kicad-target.sh b/scripts/kicad/build-kicad-target.sh index 807e480..e2a84d3 100755 --- a/scripts/kicad/build-kicad-target.sh +++ b/scripts/kicad/build-kicad-target.sh @@ -66,15 +66,18 @@ case "$APP_NAME" in KICAD_TARGET="pcb_calculator" KICAD_SUBDIR="pcb_calculator" ;; - sym_convert) - # Standalone .lib -> .kicad_sym converter + --lint CLI (node). Its - # add_executable lives in eeschema/CMakeLists.txt (gated by - # KICAD_SYM_CONVERTER_WASM), so artifacts land in the eeschema/ subdir - # of its own kicad-sym_convert tree. A headless CLI has no 3D viewer: - # skip the wasm/gl1 FFP shim compile (it needs glm, which the libs - # sysroot doesn't guarantee) unless the caller forces it. - KICAD_TARGET="sym_convert" - KICAD_SUBDIR="eeschema" + kicad_tools) + # Merged headless CLI (pcbjam-mcp 0001 tier 3a): both dieted kifaces — + # .lib conversion (--convert-lib), --lint, --erc, --netlist, --bom, + # --plot, --drc in one node image. (Supersedes the retired standalone + # sym_convert / pcb_convert apps.) Its add_executable lives in + # wasm/tools/ (added by the fork's top-level CMakeLists under + # -DKICAD_TOOLS_WASM=ON); the binary dir doubles as the artifact + # subdir of its own kicad-kicad_tools tree. A headless CLI has no 3D + # viewer: skip the wasm/gl1 FFP shim compile (it needs glm, which the + # libs sysroot doesn't guarantee) unless the caller forces it. + KICAD_TARGET="kicad_tools" + KICAD_SUBDIR="kicad_tools" BUILD_3D_VIEWER="${BUILD_3D_VIEWER:-OFF}" ;; occ_service) @@ -86,17 +89,17 @@ case "$APP_NAME" in KICAD_SUBDIR="occ_service" ;; *) - echo "Error: unknown app '$APP_NAME' (expected: kicad_editor | pcbnew | eeschema | calculator | pl_editor | gerbview | sym_convert | occ_service)" >&2 + echo "Error: unknown app '$APP_NAME' (expected: kicad_editor | pcbnew | eeschema | calculator | pl_editor | gerbview | kicad_tools | occ_service)" >&2 exit 1 ;; esac # Which app's embind bindings to compile + link. Most apps use their own. -# NOTE sym_convert deliberately does NOT reuse eeschema's embind object even -# though it links the eeschema kiface: the kiface references exactly one embind -# symbol (kicadCollabOnSave), and linking the real bindings TU for it roots the +# NOTE kicad_tools deliberately does NOT reuse the editors' embind objects even +# though it links both kifaces: each kiface references exactly one embind +# symbol (kicadCollabOnSave), and linking a real bindings TU for it roots the # whole editor surface from .init_array (~4x binary size — ysync 0009 size -# research). Its own wasm/bindings/sym_convert_embind.cpp provides a no-op hook. +# research). Its own wasm/bindings/kicad_tools_embind.cpp provides a no-op hook. case "$APP_NAME" in # occ_service links the pcbnew kiface objects → pcbnew's embind object (its # own embind entry points live in occ_service_main.cpp, compiled inside the @@ -105,20 +108,21 @@ case "$APP_NAME" in *) EMBIND_APP="$APP_NAME" ;; esac -# Embind linker support (--bind). sym_convert has no bindings at all (see +# Embind linker support (--bind). kicad_tools has no bindings at all (see # above) — dropping --bind keeps the embind JS/native runtime out entirely. case "$APP_NAME" in - sym_convert) EMBIND_LINK_FLAG="" ;; + kicad_tools) EMBIND_LINK_FLAG="" ;; *) EMBIND_LINK_FLAG="--bind" ;; esac # Which app's WASM stub libraries (scripting/frame placeholders) to link. -# sym_convert links the eeschema kiface objects, so it needs eeschema's frame -# stub (eeschema_frame_stub.cpp). kicad_editor links pcbnew's kiface objects, which -# reference the action-plugin scripting placeholders (pcbnewGet*); eeschema's frame -# stub arrives via CMake (target_sources on eeschema_kiface_objects), not this path. +# kicad_editor links pcbnew's kiface objects, which reference the action-plugin +# scripting placeholders (pcbnewGet*); eeschema's frame stub arrives via CMake +# (target_sources on eeschema_kiface_objects), not this path. case "$APP_NAME" in - sym_convert) STUB_APP="eeschema" ;; + # kicad_tools links both kifaces: eeschema's frame stub comes through this + # path, pcbnew's via CMake PCBNEW_WASM_STUBS. + kicad_tools) STUB_APP="eeschema" ;; kicad_editor) STUB_APP="pcbnew" ;; # occ_service links the pcbnew kiface objects → pcbnew's stubs (frame + # action-plugin scripting placeholders), like the editors. @@ -356,7 +360,7 @@ log_info "Stub libraries built" # Step 6.2/6.3: wasm-opt + wasm-emscripten-finalize handling. # For the editor apps these tools OOM on the huge debug wasm, so we stub them in # the container and run them on the host (docker/build.sh phase 2). The small, -# debug-stripped (-g0) converter finalizes fine in-container, so for sym_convert +# debug-stripped (-g0) CLI finalizes fine in-container, so for kicad_tools # we restore/keep the real tools and skip host post-processing entirely. if [ -z "${EMSDK}" ]; then log_error "EMSDK environment variable is not set." @@ -365,7 +369,7 @@ fi EMSDK_WASM_OPT="${EMSDK}/upstream/bin/wasm-opt" EMSDK_FINALIZE="${EMSDK}/upstream/bin/wasm-emscripten-finalize" -if [ "${APP_NAME}" = "sym_convert" ] || [ "${APP_NAME}" = "occ_service" ]; then +if [ "${APP_NAME}" = "kicad_tools" ] || [ "${APP_NAME}" = "occ_service" ]; then # Use the real tools so the small -g0 module is fully finalized inside the # container (no host post-processing / asyncify for these targets). [ -f "${EMSDK_WASM_OPT}.real" ] && cp "${EMSDK_WASM_OPT}.real" "${EMSDK_WASM_OPT}" @@ -434,11 +438,12 @@ if command -v ccache &> /dev/null; then log_info "Using ccache for compilation" fi -# The standalone converter is a gated eeschema target; enabling the option also -# trims the SCH_IO factory to the two KiCad plugins (no pcbjam/http) for this tree. -SYM_CONVERTER_CMAKE_FLAG="" -if [ "${APP_NAME}" = "sym_convert" ]; then - SYM_CONVERTER_CMAKE_FLAG="-DKICAD_SYM_CONVERTER_WASM=ON" +# The merged headless CLI configures with BOTH diet options (each trims its +# own kiface for this tree; the eeschema one also trims the SCH_IO factory to +# the two KiCad plugins) plus the wasm/tools/ subdir gate. +KICAD_TOOLS_CMAKE_FLAG="" +if [ "${APP_NAME}" = "kicad_tools" ]; then + KICAD_TOOLS_CMAKE_FLAG="-DKICAD_TOOLS_WASM=ON -DKICAD_SYM_CONVERTER_WASM=ON -DKICAD_PCB_CONVERTER_WASM=ON" fi # Merged pcbnew+eeschema editor: gates the wasm/editor/ subdir, the per-engine @@ -490,8 +495,17 @@ fi # Mirrors the wasm/gl1 pattern (compile to .o, add to the link). Shim: # wasm/shims/nanosleep_yield.c; its EM_ASYNC_JS yield is covered by env.__asyncjs__* in # scripts/common/asyncify-imports.txt. -emcc -c -pthread "${PROJECT_ROOT}/wasm/shims/nanosleep_yield.c" -o "${STUBS_BUILD}/nanosleep_yield.o" -NANOSLEEP_YIELD_LINK="${STUBS_BUILD}/nanosleep_yield.o" +# The synchronous node CLIs (ASYNCIFY=0) must NOT link it: their Asyncify JS +# runtime doesn't exist, so the shim's yield throws "Asyncify is not defined" +# on the first main-thread sleep (e.g. DRC copper-clearance's worker-poll). +# A blocking CLI wants libc's real blocking nanosleep anyway — node permits +# Atomics.wait on its main thread. +if [ "${APP_NAME}" = "kicad_tools" ] || [ "${APP_NAME}" = "occ_service" ]; then + NANOSLEEP_YIELD_LINK="" +else + emcc -c -pthread "${PROJECT_ROOT}/wasm/shims/nanosleep_yield.c" -o "${STUBS_BUILD}/nanosleep_yield.o" + NANOSLEEP_YIELD_LINK="${STUBS_BUILD}/nanosleep_yield.o" +fi # mallinfo() stub for the mimalloc build: -sMALLOC=mimalloc doesn't export the # glibc mallinfo() that OpenCASCADE's OSD_MemInfo.cxx (libTKernel, pcbnew's 3D) @@ -518,7 +532,7 @@ fi emcmake cmake "${KICAD_DIR}" \ ${CCACHE_OPTS} \ - ${SYM_CONVERTER_CMAKE_FLAG} \ + ${KICAD_TOOLS_CMAKE_FLAG} \ ${MERGED_EDITOR_CMAKE_FLAG} \ ${OCC_SERVICE_CMAKE_FLAG} \ -DCMAKE_BUILD_TYPE=${BUILD_TYPE} \ @@ -672,7 +686,7 @@ emmake make -j${JOBS} "${KICAD_TARGET}" # Step 8.1: Build bitmap resources (images.tar.gz) # This creates the icon archive that KiCad loads at runtime. The headless # converter/service targets have no GUI/icons, so skip it. -if [ "${APP_NAME}" != "sym_convert" ] && [ "${APP_NAME}" != "occ_service" ]; then +if [ "${APP_NAME}" != "kicad_tools" ] && [ "${APP_NAME}" != "occ_service" ]; then kw_stage kicad-bitmaps log_info "Building bitmap resources..." emmake make bitmap_archive_build diff --git a/scripts/kicad/build-kicad_tools.sh b/scripts/kicad/build-kicad_tools.sh new file mode 100755 index 0000000..cd3562e --- /dev/null +++ b/scripts/kicad/build-kicad_tools.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Build the merged headless KiCad CLI (kicad_tools: sym_convert + pcb_convert +# subcommands in one node WASM image). 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" kicad_tools "$@" diff --git a/scripts/kicad/build-sym_convert.sh b/scripts/kicad/build-sym_convert.sh deleted file mode 100755 index b1d6414..0000000 --- a/scripts/kicad/build-sym_convert.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -# Build the standalone .lib -> .kicad_sym converter (sym_convert) for WebAssembly, -# as a node CLI. 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" sym_convert "$@" diff --git a/wasm/bindings/kicad_tools_embind.cpp b/wasm/bindings/kicad_tools_embind.cpp new file mode 100644 index 0000000..72daf18 --- /dev/null +++ b/wasm/bindings/kicad_tools_embind.cpp @@ -0,0 +1,9 @@ +// kicad_tools' "embind" object — deliberately embind-free, mirroring +// sym_convert_embind.cpp / pcb_convert_embind.cpp (ysync 0009 size research). +// +// Both dieted kifaces reference exactly ONE editor-embind symbol: +// kicadCollabOnSave (eeschema files-io.cpp / pcbnew files.cpp save paths — +// neither executes headless, and both TUs are pruned from the diets anyway). +// This no-op hook satisfies any remaining reference without rooting the +// editor surface. +extern "C" void kicadCollabOnSave( const char* /* aPath */ ) {} diff --git a/wasm/bindings/sym_convert_embind.cpp b/wasm/bindings/sym_convert_embind.cpp deleted file mode 100644 index d9ddb5f..0000000 --- a/wasm/bindings/sym_convert_embind.cpp +++ /dev/null @@ -1,11 +0,0 @@ -// sym_convert's "embind" object — deliberately embind-free (ysync 0009 size -// research, step (a)). -// -// The eeschema kiface references exactly ONE symbol from the editor's embind -// TU: kicadCollabOnSave (files-io.cpp, called from SCH_EDIT_FRAME::SaveEEFile — -// a path the headless converter never executes). Linking the real -// eeschema_embind.o for that one symbol roots the ENTIRE editor surface from -// .init_array (EMSCRIPTEN_BINDINGS takes the address of every bound function: -// kicadOpenFile → frames/tools/dialogs, collab bridge, presence → GAL), which -// GC cannot remove. This no-op hook satisfies the reference instead. -extern "C" void kicadCollabOnSave( const char* /* aPath */ ) {} diff --git a/wasm/cli/kicad_tools_main.cpp b/wasm/cli/kicad_tools_main.cpp new file mode 100644 index 0000000..cb5b144 --- /dev/null +++ b/wasm/cli/kicad_tools_main.cpp @@ -0,0 +1,50 @@ +/* + * kicad_tools — merged headless KiCad CLI, built as ONE WebAssembly module + * linking both dieted kifaces (pcbjam-mcp 0001 tier 3a). + * + * Thin dispatcher over the two per-app entry points (compiled into this image + * with KICAD_TOOLS_COMBINED, which strips their standalone main()s): + * + * pcbnew side (pcb_convert_main.cpp): + * kicad_tools --drc [--json] [--strict] [] + * + * eeschema side (sym_convert_main.cpp) — everything else: + * kicad_tools --convert-lib + * kicad_tools --lint [--strict] [...] + * (.kicad_pcb files get a FULL parse here — the pcbnew + * parser is linked; the lint driver calls back into + * pcbToolsLintBoard on the pcb side) + * kicad_tools --erc [--json] [--strict] [] + * kicad_tools --netlist [--xml] [] + * kicad_tools --bom [] + * kicad_tools --plot [--pdf] [] + * + * Each side brings up its own minimal PGM runtime on first use; dispatch is + * exclusive per process, so the two runtimes never coexist. + */ + +#include +#include +#include + +int symConvertMain( int argc, char** argv ); +int pcbConvertMain( int argc, char** argv ); + + +int main( int argc, char** argv ) +{ + int rc; + + if( argc >= 2 && std::strcmp( argv[1], "--drc" ) == 0 ) + rc = pcbConvertMain( argc, argv ); + else + rc = symConvertMain( argc, argv ); + + // Skip the EXIT_RUNTIME static-dtor pass (same rationale as + // pcb_convert_main.cpp, which already does this on its --drc path): with + // the pcbnew kiface in the image, some dieted-out teardown is reachable + // through vtable slots from static dtors and traps ("table index is out + // of bounds") AFTER the subcommand finished, clobbering the exit code. + std::fflush( nullptr ); + _exit( rc ); +} diff --git a/wasm/cli/pcb_convert_main.cpp b/wasm/cli/pcb_convert_main.cpp new file mode 100644 index 0000000..1003aa1 --- /dev/null +++ b/wasm/cli/pcb_convert_main.cpp @@ -0,0 +1,481 @@ +/* + * pcb_convert — standalone KiCad board CLI, built as a WebAssembly module. + * The pcbnew-side sibling of sym_convert (pcbjam-mcp 0001 tier 3a). + * + * drc: pcb_convert --drc [--json] [--strict] [] + * Headless DRC. Replicates the scripting LoadBoard() path (the + * real one is Python-scripting code, stubbed to nullptr in WASM + * builds): PCB_IO_MGR::Load + DRC_ENGINE::InitEngine(.kicad_dru) + * + connectivity build, then runs DRC_ENGINE::RunTests the way + * kicad-cli's JobExportDrc does — no parity (needs the eeschema + * kiface over kiway), no zone refill (tools/ pruned; existing + * fills are checked as-is), no footprint-lib preload (the two + * library-parity tests are force-ignored). + * Writes a kicad-cli-compatible report (text, or JSON with + * --json) to (default: -drc.rpt/.json). + * Exit: 0 clean, 1 violations (--strict: also warnings + + * unconnected), 2 usage, 4 load/run failure. + * + * No GUI, no renderer, no embind bindings. Wired into pcbnew/CMakeLists.txt + * behind the KICAD_PCB_CONVERTER_WASM option (see + * scripts/kicad/build-pcb_convert.sh). + * + * GPL note: this is GPL KiCad code. The artifact is meant to be invoked as a + * separate process from closed code, never linked into it. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// ── minimal KiCad runtime (mirror of sym_convert_main.cpp's LINT_PGM) ───────── + +namespace +{ + +/** PCB_CONVERT_TRACE=1: stage prints for diagnosing hangs/traps in the field. */ +void trace( const char* aMsg ) +{ + if( std::getenv( "PCB_CONVERT_TRACE" ) ) + std::fprintf( stderr, "[trace] %s\n", aMsg ); +} + + +class DRC_PGM : public PGM_BASE +{ +public: + void MacOpenFile( const wxString& ) override {} + + void CreateSettingsManager() + { + m_settings_manager = std::make_unique(); + } + + // The full InitPgm() is deliberately not run (kiway/curl plumbing); but + // the DRC engine parallelizes through GetThreadPool(), which only exists + // after m_singleton.Init() — without it the pool call wanders on a + // null-object read (address 0 is readable linear memory under wasm). + void CreateSingleton() + { + m_singleton.Init(); + } + + // Pgm().GetLibraryManager() returns *m_library_manager unchecked; an empty + // manager (no tables) is valid and keeps any stray lookup from wandering. + void CreateLibraryManager() + { + m_library_manager = std::make_unique(); + } +}; + + +SETTINGS_MANAGER& kiRuntime() +{ + static SETTINGS_MANAGER* s_manager = nullptr; + + if( !s_manager ) + { + // JSON settings need a writable config dir; keep it away from any real + // user config (0 = don't overwrite an explicit override). + setenv( "KICAD_CONFIG_HOME", "/tmp/pcb_convert-config", 0 ); + + // Pin the KiCad thread pool to one worker BEFORE the first + // ADVANCED_CFG::GetCfg() call latches the value: the link ships only + // -sPTHREAD_POOL_SIZE=2 preloaded pthread workers, and the default + // (0 = hardware_concurrency) would spawn a pool the node runtime can + // only grow after returning to the event loop — which a blocking CLI + // never does. + { + const char* configHome = std::getenv( "KICAD_CONFIG_HOME" ); + wxFileName advCfg( wxString::FromUTF8( configHome ), wxS( "kicad_advanced" ) ); + + if( !advCfg.DirExists() ) + advCfg.Mkdir( wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL ); + + if( !advCfg.FileExists() ) + { + if( FILE* f = std::fopen( advCfg.GetFullPath().ToUTF8(), "wb" ) ) + { + std::fputs( "MaximumThreads=1\n", f ); + std::fclose( f ); + } + } + } + + // Deliberately leaked: ~PGM_BASE runs Destroy() (curl/sentry cleanup) + // from the EXIT_RUNTIME static-dtor pass. + trace( "kiRuntime: constructing DRC_PGM" ); + DRC_PGM* pgm = new DRC_PGM(); + SetPgm( pgm ); + trace( "kiRuntime: constructing SETTINGS_MANAGER" ); + pgm->CreateSettingsManager(); + trace( "kiRuntime: singleton (thread pool)" ); + pgm->CreateSingleton(); + trace( "kiRuntime: library manager (empty)" ); + pgm->CreateLibraryManager(); + s_manager = &pgm->GetSettingsManager(); + trace( "kiRuntime: ready" ); + } + + return *s_manager; +} + + +// ── headless board load ─────────────────────────────────────────────────────── +// Replica of the scripting LoadBoard() (pcbnew_scripting_helpers.cpp:153), +// which the WASM build stubs to nullptr (it lives behind KICAD_SCRIPTING and +// #includes Python.h). Prints diagnostics and returns null on failure. + +BOARD* loadBoardHeadless( const char* aInPath ) +{ + wxFileName fn( wxString::FromUTF8( aInPath ) ); + fn.MakeAbsolute(); + const wxString absPath = fn.GetFullPath(); + + SETTINGS_MANAGER& manager = kiRuntime(); + + wxFileName pro( fn ); + pro.SetExt( wxS( "kicad_pro" ) ); + + // A board can embed bitmap images in several formats. + wxInitAllImageHandlers(); + + // aSetActive=false: the set-active tail needs kiway plumbing that doesn't + // exist headless (mirrors sym_convert's lint loader). + trace( "loadBoardHeadless: LoadProject" ); + manager.LoadProject( pro.FileExists() ? pro.GetFullPath() : wxString( wxEmptyString ), false ); + PROJECT& project = manager.Prj(); + + BASE_SCREEN::m_DrawingSheetFileName = project.GetProjectFile().m_BoardDrawingSheetFile; + + trace( "loadBoardHeadless: PCB_IO_MGR::Load" ); + BOARD* brd = nullptr; + + try + { + brd = PCB_IO_MGR::Load( PCB_IO_MGR::KICAD_SEXP, absPath ); + } + catch( PARSE_ERROR& pe ) + { + std::fprintf( stderr, "%s:%d:%d: error: %s\n", aInPath, pe.lineNumber, pe.byteIndex, + (const char*) pe.ParseProblem().ToUTF8() ); + return nullptr; + } + catch( const IO_ERROR& ioe ) + { + std::fprintf( stderr, "%s: error: %s\n", aInPath, + (const char*) ioe.Problem().ToUTF8() ); + return nullptr; + } + + if( !brd ) + { + std::fprintf( stderr, "%s: error: failed to load board\n", aInPath ); + return nullptr; + } + + // Custom DRC rule conditions (A.Layer == 'F.Cu', …) resolve layer names + // through the property system's PCB_LAYER_ID enum map. + trace( "loadBoardHeadless: layer enum map" ); + ENUM_MAP& layerEnum = ENUM_MAP::Instance(); + + layerEnum.Choices().Clear(); + layerEnum.Undefined( UNDEFINED_LAYER ); + + for( PCB_LAYER_ID layer : LSET::AllLayersMask() ) + { + layerEnum.Map( layer, LSET::Name( layer ) ); // canonical name + layerEnum.Map( layer, brd->GetLayerName( layer ) ); // user name + } + + brd->SetProject( &project ); + + trace( "loadBoardHeadless: DRC_ENGINE InitEngine" ); + BOARD_DESIGN_SETTINGS& bds = brd->GetDesignSettings(); + bds.m_DRCEngine = std::make_shared( brd, &bds ); + + try + { + wxFileName rules( pro ); + rules.SetExt( wxS( "kicad_dru" ) ); + bds.m_DRCEngine->InitEngine( rules ); + } + catch( ... ) + { + // Best efforts — implicit (board-settings) rules still apply. + std::fprintf( stderr, "%s: warning: custom DRC rules failed to load\n", aInPath ); + } + + for( PCB_MARKER* marker : brd->ResolveDRCExclusions( true ) ) + brd->Add( marker ); + + trace( "loadBoardHeadless: BuildConnectivity" ); + brd->BuildConnectivity(); + brd->BuildListOfNets(); + brd->SynchronizeNetsAndNetClasses( true ); + + // Component-class assignment rules from the project; without this, + // hasComponentClass() conditions in custom rules never match. + brd->SynchronizeComponentClasses( std::unordered_set() ); + + brd->UpdateUserUnits( brd, nullptr ); + + return brd; +} + + +// ── headless DRC ────────────────────────────────────────────────────────────── +// Mirrors PCBNEW_JOBS_HANDLER::JobExportDrc with the headless deltas: no +// parity (kiway/eeschema kiface), no zone refill (tool framework pruned), no +// footprint-lib preload (the two library tests are force-ignored), markers +// added straight to the board instead of through a BOARD_COMMIT. + +int runDrc( const char* aInPath, bool aJson, bool aStrict, const char* aOutPath ) +{ + wxFileName fn( wxString::FromUTF8( aInPath ) ); + fn.MakeAbsolute(); + + BOARD* brd = loadBoardHeadless( aInPath ); + + if( !brd ) + return 4; + + BOARD_DESIGN_SETTINGS& bds = brd->GetDesignSettings(); + + // Library-parity tests dereference Pgm().GetLibraryManager() adapters that + // have no tables headless; parity-with-schematic needs the eeschema kiface. + bds.m_DRCSeverities[ DRCE_LIB_FOOTPRINT_ISSUES ] = RPT_SEVERITY_IGNORE; + bds.m_DRCSeverities[ DRCE_LIB_FOOTPRINT_MISMATCH ] = RPT_SEVERITY_IGNORE; + + std::shared_ptr drcEngine = bds.m_DRCEngine; + + drcEngine->SetViolationHandler( + [&]( const std::shared_ptr& aItem, const VECTOR2I& aPos, int aLayer, + const std::function& aPathGenerator ) + { + PCB_MARKER* marker = new PCB_MARKER( aItem, aPos, aLayer ); + aPathGenerator( marker ); + brd->Add( marker ); + } ); + + trace( "runDrc: RunTests" ); + brd->RecordDRCExclusions(); + brd->DeleteMARKERs( true, true ); + drcEngine->RunTests( EDA_UNITS::MM, false /*aReportAllTrackErrors*/, false /*aTestFootprints*/ ); + drcEngine->ClearViolationHandler(); + + // Update the exclusion status on any excluded markers that still exist. + brd->ResolveDRCExclusions( false ); + + auto markersProvider = std::make_shared( + brd, MARKER_BASE::MARKER_DRC, MARKER_BASE::MARKER_DRAWING_SHEET ); + auto ratsnestProvider = std::make_shared( brd, MARKER_BASE::MARKER_RATSNEST ); + auto fpWarningsProvider = std::make_shared( brd, MARKER_BASE::MARKER_PARITY ); + + markersProvider->SetSeverities( RPT_SEVERITY_ERROR | RPT_SEVERITY_WARNING ); + ratsnestProvider->SetSeverities( RPT_SEVERITY_ERROR | RPT_SEVERITY_WARNING ); + fpWarningsProvider->SetSeverities( RPT_SEVERITY_ERROR | RPT_SEVERITY_WARNING ); + + const int errors = markersProvider->GetCount( RPT_SEVERITY_ERROR ); + const int warnings = markersProvider->GetCount( RPT_SEVERITY_WARNING ); + const int unconnected = ratsnestProvider->GetCount(); + + wxString outPath; + + if( aOutPath ) + { + outPath = wxString::FromUTF8( aOutPath ); + } + else + { + wxFileName out( fn ); + out.SetName( out.GetName() + wxS( "-drc" ) ); + out.SetExt( aJson ? wxS( "json" ) : wxS( "rpt" ) ); + outPath = out.GetFullPath(); + } + + trace( "runDrc: writing report" ); + DRC_REPORT reportWriter( brd, EDA_UNITS::MM, markersProvider, ratsnestProvider, + fpWarningsProvider ); + + const bool wrote = aJson ? reportWriter.WriteJsonReport( outPath ) + : reportWriter.WriteTextReport( outPath ); + + if( !wrote ) + { + std::fprintf( stderr, "%s: error: unable to save DRC report to %s\n", aInPath, + (const char*) outPath.ToUTF8() ); + return 4; + } + + // Same convention as sym_convert --erc: errors fail; --strict also fails + // on warnings and unconnected items. + const bool failed = errors > 0 || ( aStrict && ( warnings > 0 || unconnected > 0 ) ); + + std::fprintf( stderr, "%s: %s (%d errors, %d warnings, %d unconnected) -> %s\n", aInPath, + failed ? "FAIL" : "OK", errors, warnings, unconnected, + (const char*) outPath.ToUTF8() ); + + return failed ? 1 : 0; +} + +} // namespace + + +#ifdef KICAD_TOOLS_COMBINED +// Full-parse board lint for the merged image's --lint driver (which lives on +// the eeschema side, sym_convert_main.cpp): a bare PCB_IO_MGR::Load parse — +// no project, no DRC engine, no connectivity. Returns the footprint count, or +// -1 with aError filled ("path:line:col: error: ..." on parse errors). +int pcbToolsLintBoard( const char* aInPath, std::string& aError ) +{ + wxFileName fn( wxString::FromUTF8( aInPath ) ); + fn.MakeAbsolute(); + + kiRuntime(); + + try + { + std::unique_ptr brd( PCB_IO_MGR::Load( PCB_IO_MGR::KICAD_SEXP, fn.GetFullPath() ) ); + + if( !brd ) + { + aError = std::string( aInPath ) + ": error: failed to load board"; + return -1; + } + + return (int) brd->Footprints().size(); + } + catch( PARSE_ERROR& pe ) + { + char buf[1024]; + std::snprintf( buf, sizeof( buf ), "%s:%d:%d: error: %s", aInPath, pe.lineNumber, + pe.byteIndex, (const char*) pe.ParseProblem().ToUTF8() ); + aError = buf; + return -1; + } + catch( const IO_ERROR& ioe ) + { + aError = std::string( aInPath ) + ": error: " + std::string( ioe.Problem().ToUTF8() ); + return -1; + } + catch( const std::exception& e ) + { + aError = std::string( aInPath ) + ": error: " + e.what(); + return -1; + } +} +#endif // KICAD_TOOLS_COMBINED + + +// Under KICAD_TOOLS_COMBINED (the merged kicad_tools image) this TU compiles +// as a library: the entry point keeps its name and kicad_tools_main.cpp +// dispatches to it; the standalone pcb_convert build wraps it in main() below. +int pcbConvertMain( int argc, char** argv ) +{ + // Non-tty stderr is fully buffered under emscripten/musl; a CLI's stderr + // must be unbuffered — losing the error report is worse than the syscall + // cost. + setvbuf( stderr, nullptr, _IONBF, 0 ); + + wxInitializer initializer( argc, argv ); + + if( !initializer.IsOk() ) + { + std::fprintf( stderr, "pcb_convert: wxWidgets initialisation failed\n" ); + return 3; + } + + if( argc >= 2 && std::strcmp( argv[1], "--drc" ) == 0 ) + { + // The minimal headless runtime never registers app settings; + // GetAppSettings fails SOFT to defaults but wxFAIL_MSGs on every call. + // Report output must stay parseable — drop the asserts. + wxDisableAsserts(); + + bool json = false; + bool strict = false; + int arg = 2; + + while( arg < argc && std::strncmp( argv[arg], "--", 2 ) == 0 ) + { + if( std::strcmp( argv[arg], "--json" ) == 0 ) + json = true; + else if( std::strcmp( argv[arg], "--strict" ) == 0 ) + strict = true; + else + break; + + arg++; + } + + if( arg >= argc ) + { + std::fprintf( stderr, + "usage: pcb_convert --drc [--json] [--strict] []\n" ); + return 2; + } + + const char* inPath = argv[arg++]; + const char* outPath = arg < argc ? argv[arg] : nullptr; + + int rc = 4; + + try + { + rc = runDrc( inPath, json, strict, outPath ); + } + catch( const std::exception& e ) + { + std::fprintf( stderr, "%s: error: %s\n", inPath, e.what() ); + } + + // Skip the EXIT_RUNTIME static-dtor pass: some dieted-out editor + // teardown is still reachable through vtable slots from static dtors + // and traps ("table index is out of bounds") AFTER the report is + // written, clobbering the exit code. A CLI has nothing to tear down — + // flush and leave. + std::fflush( nullptr ); + _exit( rc ); + } + + std::fprintf( stderr, "usage: pcb_convert --drc [--json] [--strict] []\n" ); + return 2; +} + + +#ifndef KICAD_TOOLS_COMBINED +int main( int argc, char** argv ) +{ + return pcbConvertMain( argc, argv ); +} +#endif diff --git a/wasm/cli/pcb_convert_stubs.cpp b/wasm/cli/pcb_convert_stubs.cpp new file mode 100644 index 0000000..5baaef7 --- /dev/null +++ b/wasm/cli/pcb_convert_stubs.cpp @@ -0,0 +1,15 @@ +/* + * pcb_convert link-order diet stubs (mirror of sym_convert_stubs.cpp). + * + * Listed before the kiface objects in the pcb_convert link so + * --allow-multiple-definition resolves duplicated definitions to these + * (first definition wins). Grows as the diet surfaces link/runtime needs; + * function references left dangling by the CMake prune are covered by + * -sERROR_ON_UNDEFINED_SYMBOLS=0 (they become aborting JS imports), so only + * data/typeinfo needs and behavior overrides belong here. + * + * Currently empty: the --drc path needs no overrides — board *save* and the + * plot/export surfaces are pruned outright, and Kiface() is deliberately left + * undefined so a stray call aborts loudly instead of wandering on a null + * settings object. + */ diff --git a/wasm/cli/sym_convert_main.cpp b/wasm/cli/sym_convert_main.cpp index 959cc92..995e6c0 100644 --- a/wasm/cli/sym_convert_main.cpp +++ b/wasm/cli/sym_convert_main.cpp @@ -4,15 +4,46 @@ * Two modes, one binary (ysync 0009 §7 — the lint tier rides the dieted * converter instead of shipping a second wasm): * - * convert: sym_convert + * convert: --convert-lib * legacy (.lib) -> S-expression (.kicad_sym) symbol-library - * conversion via SCH_IO_MGR::ConvertLibrary. Unchanged behavior. + * conversion via SCH_IO_MGR::ConvertLibrary. Paths are absolutized + * here (the legacy plugin writes an empty lib on relative paths + * while exiting 0). + * + * erc: sym_convert --erc [--json] [--strict] [] + * Headless ERC (pcbjam-mcp 0001 tier 3a). Loads the schematic with + * the EESCHEMA_HELPERS::LoadSchematic post-load tail — minus the + * SCH_COMMIT / TOOL_MANAGER / Kiface() cleanup chain this diet tree + * doesn't link — builds the connection graph, and runs ERC_TESTER + * the way kicad-cli's JobSchErc does (no edit frame, no cvpcb). + * Tests needing machinery absent headless are force-ignored: + * lib-symbol issues + footprint filters (null LIBRARY_MANAGER), + * footprint links (no cvpcb kiface), sim models (sim/ TUs pruned). + * Writes a kicad-cli-compatible report (text, or JSON with --json) + * to (default: -erc.rpt/.json next to the input). + * Exit: 0 clean, 1 ERC errors (--strict: also warnings), 2 usage, + * 4 load/run failure. + * + * netlist: sym_convert --netlist [--xml] [] + * bom: sym_convert --bom [] + * KiCad s-expr netlist (default), XML netlist (--xml), or XML BOM + * (GNL_OPT_BOM, kicad-cli's python-bom) — mirrors JobExportNetlist / + * JobExportPythonBom on the same headless loader as --erc. SPICE + * and the legacy vendor formats are not linked (sim/ pruned; the + * vendor emitters can be re-admitted in CMake on demand). + * + * plot: sym_convert --plot [--pdf] [] + * SVG (one file per sheet, into dir, default = input's dir) + * or a single multi-page PDF. SCH_PLOTTER + common plotter + * backends — no GAL/GL context; stroke-font text. * * lint: sym_convert --lint [--strict] [...] * "OK" = KiCad will load it (not necessarily load it UNCHANGED — * KiCad normalizes while parsing). Per extension: * .kicad_sch full parse (SCH_IO_KICAD_SEXPR) * .kicad_sym / .lib full library parse (EnumerateSymbolLib) + * .kicad_pcb full parse (PCB_IO_MGR — merged + * kicad_tools image only) * other s-expr files structure-only (parens/strings/atoms) * Every s-expr input additionally gets the uuid lints: duplicate * (uuid) fields inside one node (KiCad keeps the last) and one @@ -41,6 +72,7 @@ #include #include #include +#include #include #include @@ -50,6 +82,7 @@ #include #include +#include #include #include #include @@ -59,8 +92,31 @@ #include #include #include +#include #include +#ifdef KICAD_TOOLS_COMBINED +// Merged image only: full-parse board lint via the pcbnew side +// (pcb_convert_main.cpp) — the standalone eeschema tree has no pcbnew parser. +int pcbToolsLintBoard( const char* aInPath, std::string& aError ); +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + // ── minimal KiCad runtime for the schematic-load path ───────────────────────── // LoadSchematicFile needs a SCHEMATIC with a PROJECT (settings manager), and // ParseSchematic's tail calls Pgm().GetLanguageTag() (feeding the wasm @@ -88,6 +144,25 @@ public: { m_settings_manager = std::make_unique(); } + + // The full InitPgm() is deliberately not run (kiway/curl plumbing); but + // CONNECTION_GRAPH::Recalculate's submit_loop divides by GetThreadPool()'s + // thread count, and the pool only exists after m_singleton.Init() — + // without it --erc wanders on a null-object read (address 0 is readable + // linear memory under wasm, so null derefs hang instead of trapping). + void CreateSingleton() + { + m_singleton.Init(); + } + + // Same hazard class: netlist export's makeLibraries() (and the ERC lib + // checks) call Pgm().GetLibraryManager(), which returns *m_library_manager + // unchecked. An empty manager (no tables loaded) is valid — lookups just + // return nullopt and the netlist's section stays empty. + void CreateLibraryManager() + { + m_library_manager = std::make_unique(); + } }; @@ -101,6 +176,29 @@ SETTINGS_MANAGER& kiRuntime() // user config (0 = don't overwrite an explicit override). setenv( "KICAD_CONFIG_HOME", "/tmp/sym_convert-config", 0 ); + // Pin the KiCad thread pool to one worker BEFORE the first + // ADVANCED_CFG::GetCfg() call latches the value: the link ships only + // -sPTHREAD_POOL_SIZE=2 preloaded pthread workers, and the default + // (0 = hardware_concurrency) would spawn a pool the node runtime can + // only grow after returning to the event loop — which a blocking CLI + // never does. + { + const char* configHome = std::getenv( "KICAD_CONFIG_HOME" ); + wxFileName advCfg( wxString::FromUTF8( configHome ), wxS( "kicad_advanced" ) ); + + if( !advCfg.DirExists() ) + advCfg.Mkdir( wxS_DIR_DEFAULT, wxPATH_MKDIR_FULL ); + + if( !advCfg.FileExists() ) + { + if( FILE* f = std::fopen( advCfg.GetFullPath().ToUTF8(), "wb" ) ) + { + std::fputs( "MaximumThreads=1\n", f ); + std::fclose( f ); + } + } + } + // Deliberately leaked: ~PGM_BASE runs Destroy() (curl/sentry cleanup) // from the EXIT_RUNTIME static-dtor pass, which the diet stubs out. trace( "kiRuntime: constructing LINT_PGM" ); @@ -108,6 +206,10 @@ SETTINGS_MANAGER& kiRuntime() SetPgm( pgm ); trace( "kiRuntime: constructing SETTINGS_MANAGER" ); pgm->CreateSettingsManager(); + trace( "kiRuntime: singleton (thread pool)" ); + pgm->CreateSingleton(); + trace( "kiRuntime: library manager (empty)" ); + pgm->CreateLibraryManager(); s_manager = &pgm->GetSettingsManager(); trace( "kiRuntime: ready" ); } @@ -380,6 +482,7 @@ bool lintOneFile( const char* aPath, bool aStrict ) LINT_REPORT report; const char* tier = "structure only"; int symbolCount = -1; + int footprintCount = -1; // The structural walk + uuid lints run on every s-expr format; the legacy // .lib format is not an s-expr, so it gets the full parse only. @@ -422,9 +525,20 @@ bool lintOneFile( const char* aPath, bool aStrict ) symbolCount = lintSymbolLib( absPath ); tier = "full parse"; } - // Anything else (kicad_pcb, kicad_wks, kicad_pro …) stays - // structure-only: this binary links the eeschema parsers, not - // pcbnew's. Native kicad-cli covers boards in container contexts. +#ifdef KICAD_TOOLS_COMBINED + else if( ext == wxS( "kicad_pcb" ) ) + { + std::string boardError; + footprintCount = pcbToolsLintBoard( aPath, boardError ); + + if( footprintCount < 0 ) + report.errors.push_back( boardError ); + else + tier = "full parse"; + } +#endif + // Anything else (kicad_wks, kicad_pro …— and kicad_pcb outside + // the merged kicad_tools image) stays structure-only. } catch( PARSE_ERROR& pe ) // non-const: ParseProblem() is unqualified { @@ -456,16 +570,380 @@ bool lintOneFile( const char* aPath, bool aStrict ) std::fprintf( stderr, "%s: FAIL\n", aPath ); else if( symbolCount >= 0 ) std::fprintf( stderr, "%s: OK (%s, %d symbols)\n", aPath, tier, symbolCount ); + else if( footprintCount >= 0 ) + std::fprintf( stderr, "%s: OK (%s, %d footprints)\n", aPath, tier, footprintCount ); else std::fprintf( stderr, "%s: OK (%s)\n", aPath, tier ); return !failed; } +// ── shared headless schematic loader (pcbjam-mcp 0001 tier 3a) ─────────────── +// Load + the EESCHEMA_HELPERS::LoadSchematic post-load tail, minus the +// SCHEMATIC::RecalculateConnections cleanup pass: that path constructs a +// SCH_COMMIT on a TOOL_MANAGER wired to Kiface().KifaceSettings(), none of +// which this diet tree links. Editor-saved files are already normalized; the +// connection graph is built directly, the way the cleanup pass's own tail +// does it. Prints diagnostics and returns null on failure. + +std::unique_ptr loadSchematicHeadless( const char* aInPath ) +{ + wxFileName fn( wxString::FromUTF8( aInPath ) ); + fn.MakeAbsolute(); + const wxString absPath = fn.GetFullPath(); + + SETTINGS_MANAGER& manager = kiRuntime(); + + // ERC severities, exclusions, netclasses and text vars live in the sibling + // .kicad_pro — load it when present. aSetActive=false as in lintSchematicFile + // (the set-active tail needs the library manager / kiway plumbing). + wxFileName pro( fn ); + pro.SetExt( wxS( "kicad_pro" ) ); + trace( "loadSchematicHeadless: LoadProject" ); + manager.LoadProject( pro.FileExists() ? pro.GetFullPath() : wxString( wxEmptyString ), false ); + PROJECT& project = manager.Prj(); + project.SetElem( PROJECT::ELEM::LEGACY_SYMBOL_LIBS, nullptr ); + + auto schematic = std::make_unique( &project ); + schematic->Reset(); + SCH_SHEET* defaultSheet = schematic->GetTopLevelSheet( 0 ); + + trace( "loadSchematicHeadless: LoadSchematicFile" ); + IO_RELEASER pi( SCH_IO_MGR::FindPlugin( SCH_IO_MGR::SCH_KICAD ) ); + SCH_SHEET* root = nullptr; + + try + { + root = pi->LoadSchematicFile( absPath, schematic.get() ); + } + catch( PARSE_ERROR& pe ) + { + std::fprintf( stderr, "%s:%d:%d: error: %s\n", aInPath, pe.lineNumber, pe.byteIndex, + (const char*) pe.ParseProblem().ToUTF8() ); + return nullptr; + } + catch( const IO_ERROR& ioe ) + { + std::fprintf( stderr, "%s: error: %s\n", aInPath, + (const char*) ioe.Problem().ToUTF8() ); + return nullptr; + } + + schematic->AddTopLevelSheet( root ); // the SCHEMATIC dtor owns the hierarchy + schematic->RemoveTopLevelSheet( defaultSheet ); + delete defaultSheet; + + if( root->GetName().IsEmpty() ) + root->SetName( wxS( "Root" ) ); + + trace( "loadSchematicHeadless: post-load fixups" ); + SCH_SHEET_LIST sheetList = schematic->BuildSheetListSortedByPageNumbers(); + SCH_SCREENS screens( schematic->Root() ); + + for( SCH_SCREEN* screen = screens.GetFirst(); screen; screen = screens.GetNext() ) + screen->UpdateLocalLibSymbolLinks(); + + if( schematic->RootScreen()->GetFileFormatVersionAtLoad() < 20221002 ) + sheetList.UpdateSymbolInstanceData( schematic->RootScreen()->GetSymbolInstances() ); + + sheetList.UpdateSheetInstanceData( schematic->RootScreen()->GetSheetInstances() ); + + if( schematic->RootScreen()->GetFileFormatVersionAtLoad() < 20230221 ) + screens.FixLegacyPowerSymbolMismatches(); + + // MigrateSimModels() deliberately skipped: legacy sim-model migration lives + // in the pruned sim/ TUs (undefined here); ERCE_SIMULATION_MODEL is ignored + // in runErc for the same reason. + schematic->LoadVariants(); + + wxString projectName = project.GetProjectName(); + + if( projectName.IsEmpty() ) + projectName = fn.GetName(); + + sheetList.CheckForMissingSymbolInstances( projectName ); + screens.PruneOrphanedSymbolInstances( projectName, sheetList ); + screens.PruneOrphanedSheetInstances( projectName, sheetList ); + sheetList.AnnotatePowerSymbols(); + + schematic->ConnectionGraph()->Reset(); + schematic->ResolveERCExclusionsPostUpdate(); + schematic->SetSheetNumberAndCount(); + schematic->RecomputeIntersheetRefs(); + + for( SCH_SHEET_PATH& sheet : sheetList ) + { + sheet.UpdateAllScreenReferences(); + sheet.LastScreen()->TestDanglingEnds( nullptr, nullptr ); + } + + std::unordered_set allScreens; + + for( const SCH_SHEET_PATH& path : sheetList ) + allScreens.insert( path.LastScreen() ); + + SCH_RULE_AREA::UpdateRuleAreasInScreens( allScreens, nullptr ); + + trace( "loadSchematicHeadless: ConnectionGraph Recalculate" ); + schematic->ConnectionGraph()->Recalculate( sheetList, true ); + + return schematic; +} + + +/** Default output path: next to the input, optional suffix, new extension. */ +wxString defaultOutPath( const wxFileName& aIn, const wxString& aSuffix, const wxString& aExt ) +{ + wxFileName out( aIn ); + out.SetName( out.GetName() + aSuffix ); + out.SetExt( aExt ); + return out.GetFullPath(); +} + +// ── headless ERC ────────────────────────────────────────────────────────────── + +int runErc( const char* aInPath, bool aJson, bool aStrict, const char* aOutPath ) +{ + wxFileName fn( wxString::FromUTF8( aInPath ) ); + fn.MakeAbsolute(); + + std::unique_ptr schematicHolder = loadSchematicHeadless( aInPath ); + + if( !schematicHolder ) + return 4; + + SCHEMATIC& schematic = *schematicHolder; + PROJECT& project = schematic.Project(); + + // Tests needing machinery this headless runtime doesn't have. Lib-symbol + // and footprint-filter tests dereference Pgm().GetLibraryManager() (never + // constructed on LINT_PGM); footprint links need the cvpcb kiface (RunTests + // also skips it on the null aCvPcb); sim models need the pruned sim/ TUs. + ERC_SETTINGS& ercSettings = schematic.ErcSettings(); + ercSettings.SetSeverity( ERCE_LIB_SYMBOL_ISSUES, RPT_SEVERITY_IGNORE ); + ercSettings.SetSeverity( ERCE_LIB_SYMBOL_MISMATCH, RPT_SEVERITY_IGNORE ); + ercSettings.SetSeverity( ERCE_FOOTPRINT_FILTERS, RPT_SEVERITY_IGNORE ); + ercSettings.SetSeverity( ERCE_FOOTPRINT_LINK_ISSUES, RPT_SEVERITY_IGNORE ); + ercSettings.SetSeverity( ERCE_SIMULATION_MODEL, RPT_SEVERITY_IGNORE ); + + trace( "runErc: RunTests" ); + ERC_TESTER tester( &schematic ); + tester.RunTests( nullptr /*drawing sheet*/, nullptr /*edit frame*/, nullptr /*cvpcb*/, + &project, nullptr /*progress*/ ); + + auto provider = std::make_shared( &schematic ); + provider->SetSeverities( RPT_SEVERITY_ERROR | RPT_SEVERITY_WARNING ); + + const int errors = provider->GetCount( RPT_SEVERITY_ERROR ); + const int warnings = provider->GetCount( RPT_SEVERITY_WARNING ); + + wxString outPath; + + if( aOutPath ) + { + outPath = wxString::FromUTF8( aOutPath ); + } + else + { + wxFileName out( fn ); + out.SetName( out.GetName() + wxS( "-erc" ) ); + out.SetExt( aJson ? wxS( "json" ) : wxS( "rpt" ) ); + outPath = out.GetFullPath(); + } + + trace( "runErc: writing report" ); + ERC_REPORT reportWriter( &schematic, EDA_UNITS::MM, provider ); + const bool wrote = aJson ? reportWriter.WriteJsonReport( outPath ) + : reportWriter.WriteTextReport( outPath ); + + if( !wrote ) + { + std::fprintf( stderr, "%s: error: unable to save ERC report to %s\n", aInPath, + (const char*) outPath.ToUTF8() ); + return 4; + } + + const bool failed = errors > 0 || ( aStrict && warnings > 0 ); + + std::fprintf( stderr, "%s: %s (%d errors, %d warnings) -> %s\n", aInPath, + failed ? "FAIL" : "OK", errors, warnings, (const char*) outPath.ToUTF8() ); + + return failed ? 1 : 0; +} + +// ── headless netlist / BOM export ───────────────────────────────────────────── +// Mirrors EESCHEMA_JOBS_HANDLER::JobExportNetlist / JobExportPythonBom. Only +// the KiCad s-expr and XML emitters are linked (SPICE needs the pruned sim/ +// TUs; the other legacy formats can be re-admitted on demand). + +void warnAnnotationIssues( SCHEMATIC& aSchematic, const char* aInPath ) +{ + SCH_REFERENCE_LIST referenceList; + aSchematic.Hierarchy().GetSymbols( referenceList, SYMBOL_FILTER_ALL ); + + if( referenceList.GetCount() > 0 ) + { + if( referenceList.CheckAnnotation( + []( ERCE_T, const wxString&, SCH_REFERENCE*, SCH_REFERENCE* ) + { + } ) > 0 ) + { + std::fprintf( stderr, "%s: warning: schematic has annotation errors\n", aInPath ); + } + } + + ERC_TESTER erc( &aSchematic ); + + if( erc.TestDuplicateSheetNames( false ) > 0 ) + std::fprintf( stderr, "%s: warning: duplicate sheet names\n", aInPath ); +} + + +int runNetlist( const char* aInPath, bool aXml, bool aBom, const char* aOutPath ) +{ + wxFileName fn( wxString::FromUTF8( aInPath ) ); + fn.MakeAbsolute(); + + std::unique_ptr schematic = loadSchematicHeadless( aInPath ); + + if( !schematic ) + return 4; + + warnAnnotationIssues( *schematic, aInPath ); + + std::unique_ptr helper; + unsigned netlistOption = 0; + wxString outPath; + + if( aBom ) + { + helper = std::make_unique( schematic.get() ); + netlistOption = GNL_OPT_BOM; + outPath = aOutPath ? wxString::FromUTF8( aOutPath ) + : defaultOutPath( fn, wxS( "-bom" ), wxS( "xml" ) ); + } + else if( aXml ) + { + helper = std::make_unique( schematic.get() ); + outPath = aOutPath ? wxString::FromUTF8( aOutPath ) + : defaultOutPath( fn, wxEmptyString, wxS( "xml" ) ); + } + else + { + helper = std::make_unique( schematic.get() ); + outPath = aOutPath ? wxString::FromUTF8( aOutPath ) + : defaultOutPath( fn, wxEmptyString, wxS( "net" ) ); + } + + trace( "runNetlist: WriteNetlist" ); + const bool ok = helper->WriteNetlist( outPath, netlistOption, CLI_REPORTER::GetInstance() ); + + std::fprintf( stderr, "%s: %s -> %s\n", aInPath, ok ? "OK" : "FAIL", + (const char*) outPath.ToUTF8() ); + + return ok ? 0 : 4; +} + +// ── headless schematic plot (SVG / PDF) ─────────────────────────────────────── +// Mirrors EESCHEMA_JOBS_HANDLER::JobExportPlot + its InitRenderSettings: the +// plot path is plotter-based (common/plotters), no GAL context; text renders +// through the already-linked stroke font engine. + +int runPlot( const char* aInPath, bool aPdf, const char* aOutPath ) +{ + wxFileName fn( wxString::FromUTF8( aInPath ) ); + fn.MakeAbsolute(); + + std::unique_ptr schematic = loadSchematicHeadless( aInPath ); + + if( !schematic ) + return 4; + + auto renderSettings = std::make_unique(); + + // InitRenderSettings replica (default theme, no drawing-sheet override). + COLOR_SETTINGS* cs = ::GetColorSettings( wxEmptyString ); + renderSettings->LoadColors( cs ); + renderSettings->m_ShowHiddenPins = false; + renderSettings->m_ShowHiddenFields = false; + renderSettings->m_ShowPinAltIcons = false; + renderSettings->SetDefaultPenWidth( schematic->Settings().m_DefaultLineWidth ); + renderSettings->m_LabelSizeRatio = schematic->Settings().m_LabelSizeRatio; + renderSettings->m_TextOffsetRatio = schematic->Settings().m_TextOffsetRatio; + renderSettings->m_PinSymbolSize = schematic->Settings().m_PinSymbolSize; + renderSettings->SetDashLengthRatio( schematic->Settings().m_DashedLineDashRatio ); + renderSettings->SetGapLengthRatio( schematic->Settings().m_DashedLineGapRatio ); + renderSettings->SetDefaultFont( wxEmptyString ); // stroke font (KiCad default) + renderSettings->SetMinPenWidth( 0 ); + + // Drawing sheet: project/schematic setting, else the built-in default. + { + wxString sheetPath = schematic->Settings().m_SchDrawingSheetFileName; + wxString msg; + FILENAME_RESOLVER resolve; + resolve.SetProject( &schematic->Project() ); + resolve.SetProgramBase( &Pgm() ); + + wxString absolutePath = + resolve.ResolvePath( sheetPath, wxGetCwd(), { schematic->GetEmbeddedFiles() } ); + + if( !DS_DATA_MODEL::GetTheInstance().LoadDrawingSheet( absolutePath, &msg ) ) + std::fprintf( stderr, "%s: warning: drawing sheet load: %s\n", aInPath, + (const char*) msg.ToUTF8() ); + } + + // Text bboxes may have been cached during load with no default font set. + SCH_SCREENS screens( schematic->Root() ); + + for( SCH_SCREEN* screen = screens.GetFirst(); screen; screen = screens.GetNext() ) + { + for( SCH_ITEM* item : screen->Items() ) + item->ClearCaches(); + + for( const auto& [libItemName, libSymbol] : screen->GetLibSymbols() ) + libSymbol->ClearCaches(); + } + + SCH_PLOT_OPTS plotOpts; + plotOpts.m_plotAll = true; + plotOpts.m_plotDrawingSheet = true; + plotOpts.m_blackAndWhite = false; + + if( aPdf ) + { + // Single multi-page PDF. + plotOpts.m_outputFile = aOutPath ? wxString::FromUTF8( aOutPath ) + : defaultOutPath( fn, wxEmptyString, wxS( "pdf" ) ); + } + else + { + // One SVG per sheet into a directory (kicad-cli behavior). + plotOpts.m_outputDirectory = + aOutPath ? wxString::FromUTF8( aOutPath ) : fn.GetPath(); + } + + trace( "runPlot: SCH_PLOTTER::Plot" ); + REPORTER& reporter = CLI_REPORTER::GetInstance(); + SCH_PLOTTER plotter( schematic.get() ); + plotter.Plot( aPdf ? PLOT_FORMAT::PDF : PLOT_FORMAT::SVG, plotOpts, renderSettings.get(), + &reporter ); + + const bool failed = reporter.HasMessageOfSeverity( RPT_SEVERITY_ERROR ); + + std::fprintf( stderr, "%s: %s -> %s\n", aInPath, failed ? "FAIL" : "OK", + (const char*) ( aPdf ? plotOpts.m_outputFile : plotOpts.m_outputDirectory ) + .ToUTF8() ); + + return failed ? 4 : 0; +} + } // namespace -int main( int argc, char** argv ) +// Under KICAD_TOOLS_COMBINED (the merged kicad_tools image) this TU compiles +// as a library: the entry point keeps its name and kicad_tools_main.cpp +// dispatches to it; the standalone sym_convert build wraps it in main() below. +int symConvertMain( int argc, char** argv ) { // Non-tty stderr is fully buffered under emscripten/musl, so a trap or // hang eats every diagnostic printed before it. A CLI's stderr must be @@ -482,6 +960,98 @@ int main( int argc, char** argv ) return 3; } + if( argc >= 2 && std::strcmp( argv[1], "--erc" ) == 0 ) + { + // Same rationale as --lint: the minimal runtime never registers app + // settings, and report output must stay parseable. + wxDisableAsserts(); + + bool json = false; + bool strict = false; + int arg = 2; + + while( arg < argc && std::strncmp( argv[arg], "--", 2 ) == 0 ) + { + if( std::strcmp( argv[arg], "--json" ) == 0 ) + json = true; + else if( std::strcmp( argv[arg], "--strict" ) == 0 ) + strict = true; + else + break; + + arg++; + } + + if( arg >= argc ) + { + std::fprintf( stderr, + "usage: sym_convert --erc [--json] [--strict] []\n" ); + return 2; + } + + const char* inPath = argv[arg++]; + const char* outPath = arg < argc ? argv[arg] : nullptr; + + try + { + return runErc( inPath, json, strict, outPath ); + } + catch( const std::exception& e ) + { + std::fprintf( stderr, "%s: error: %s\n", inPath, e.what() ); + return 4; + } + } + + if( argc >= 2 && ( std::strcmp( argv[1], "--netlist" ) == 0 + || std::strcmp( argv[1], "--bom" ) == 0 + || std::strcmp( argv[1], "--plot" ) == 0 ) ) + { + wxDisableAsserts(); + + const bool isBom = std::strcmp( argv[1], "--bom" ) == 0; + const bool isPlot = std::strcmp( argv[1], "--plot" ) == 0; + bool xml = false; + bool pdf = false; + int arg = 2; + + while( arg < argc && std::strncmp( argv[arg], "--", 2 ) == 0 ) + { + if( !isPlot && !isBom && std::strcmp( argv[arg], "--xml" ) == 0 ) + xml = true; + else if( isPlot && std::strcmp( argv[arg], "--pdf" ) == 0 ) + pdf = true; + else + break; + + arg++; + } + + if( arg >= argc ) + { + std::fprintf( stderr, "usage: sym_convert --netlist [--xml] []\n" + " sym_convert --bom []\n" + " sym_convert --plot [--pdf] []\n" ); + return 2; + } + + const char* inPath = argv[arg++]; + const char* outPath = arg < argc ? argv[arg] : nullptr; + + try + { + if( isPlot ) + return runPlot( inPath, pdf, outPath ); + + return runNetlist( inPath, xml, isBom, outPath ); + } + catch( const std::exception& e ) + { + std::fprintf( stderr, "%s: error: %s\n", inPath, e.what() ); + return 4; + } + } + if( argc >= 2 && std::strcmp( argv[1], "--lint" ) == 0 ) { // The minimal headless runtime never registers app settings; @@ -512,26 +1082,45 @@ int main( int argc, char** argv ) return allOk ? 0 : 1; } - if( argc < 3 ) + if( argc >= 2 && std::strcmp( argv[1], "--convert-lib" ) == 0 && argc >= 4 ) { - std::fprintf( stderr, "usage: sym_convert \n" - " sym_convert --lint [--strict] [...]\n" ); - return 2; + // The legacy plugin asserts on relative paths and (worse) writes an + // empty lib while still exiting 0 — absolutize here so callers can't + // hit that class of bug. + wxFileName inFn( wxString::FromUTF8( argv[2] ) ); + wxFileName outFn( wxString::FromUTF8( argv[3] ) ); + inFn.MakeAbsolute(); + outFn.MakeAbsolute(); + + // aOldFileProps = nullptr: no library-table properties; ConvertLibrary + // guesses the source format from the path and writes the SCH_KICAD format. + const bool ok = SCH_IO_MGR::ConvertLibrary( nullptr, inFn.GetFullPath(), + outFn.GetFullPath() ); + + if( ok ) + { + std::fprintf( stderr, "convert-lib: OK %s -> %s\n", argv[2], argv[3] ); + return 0; + } + + std::fprintf( stderr, "convert-lib: FAILED to convert %s\n", argv[2] ); + return 1; } - const wxString inPath = wxString::FromUTF8( argv[1] ); - const wxString outPath = wxString::FromUTF8( argv[2] ); - - // aOldFileProps = nullptr: no library-table properties; ConvertLibrary - // guesses the source format from the path and writes the SCH_KICAD format. - const bool ok = SCH_IO_MGR::ConvertLibrary( nullptr, inPath, outPath ); - - if( ok ) - { - std::fprintf( stderr, "sym_convert: OK %s -> %s\n", argv[1], argv[2] ); - return 0; - } - - std::fprintf( stderr, "sym_convert: FAILED to convert %s\n", argv[1] ); - return 1; + std::fprintf( stderr, "usage: kicad_tools --convert-lib \n" + " kicad_tools --lint [--strict] [...]\n" + " kicad_tools --erc [--json] [--strict] []\n" + " kicad_tools --netlist [--xml] []\n" + " kicad_tools --bom []\n" + " kicad_tools --plot [--pdf] []\n" + " kicad_tools --drc [--json] [--strict] []\n" ); + return 2; } + + +#ifndef KICAD_TOOLS_COMBINED +int main( int argc, char** argv ) +{ + return symConvertMain( argc, argv ); +} +#endif diff --git a/wasm/tools/CMakeLists.txt b/wasm/tools/CMakeLists.txt new file mode 100644 index 0000000..8096628 --- /dev/null +++ b/wasm/tools/CMakeLists.txt @@ -0,0 +1,73 @@ +# Merged headless KiCad CLI (kicad_tools) — pcbjam-mcp 0001 tier 3a. +# +# ONE node-WASM executable links BOTH dieted kifaces and carries every +# sym_convert + pcb_convert subcommand: +# kicad_tools (legacy convert) +# kicad_tools --lint | --erc | --netlist | --bom | --plot (eeschema side) +# kicad_tools --drc (pcbnew side) +# +# Added from the kicad fork's top-level CMakeLists.txt via +# add_subdirectory( ${KICAD_WASM_LAYER}/tools ) when EMSCRIPTEN AND +# KICAD_TOOLS_WASM — after eeschema/ and pcbnew/, so both kifaces' CACHE +# INTERNAL library lists exist here (same pattern as wasm/editor/). The tree +# must configure with BOTH diet options ON (KICAD_SYM_CONVERTER_WASM + +# KICAD_PCB_CONVERTER_WASM — build-kicad-target.sh passes all three flags). +# +# Collision note: the merged *editor* needs KICAD_WASM_PCB_SIDE_RENAMES +# (Kiface() + dialog/tool-layer classes, audited via llvm-nm intersection). +# Under the two CLI diets every TU in that audited set is pruned on at least +# one side — neither tree even defines Kiface() — so no renames here. Re-audit +# with scripts/kicad/audit-merged-symbols.sh on kicad version bumps. +# +# The standalone sym_convert / pcb_convert targets still exist in this +# (options-ON) tree but are built from their own per-app trees as before. + +include_directories( BEFORE ${INC_BEFORE} ) +include_directories( + ${CMAKE_SOURCE_DIR}/common + ${CMAKE_SOURCE_DIR}/pcbnew + ${INC_AFTER} + ) + +add_executable( kicad_tools + # Link-order diet stubs: listed before the kiface objects so + # --allow-multiple-definition resolves duplicated definitions to these + # (first definition wins) — both sides' stub sets apply. + ${KICAD_WASM_LAYER}/cli/sym_convert_stubs.cpp + ${KICAD_WASM_LAYER}/cli/pcb_convert_stubs.cpp + ${KICAD_WASM_LAYER}/cli/sym_convert_main.cpp + ${KICAD_WASM_LAYER}/cli/pcb_convert_main.cpp + ${KICAD_WASM_LAYER}/cli/kicad_tools_main.cpp + ) + +# The per-app mains compile as libraries here: KICAD_TOOLS_COMBINED renames +# their entry points (symConvertMain / pcbConvertMain) and drops their own +# main(); kicad_tools_main.cpp dispatches between them. +target_compile_definitions( kicad_tools PRIVATE KICAD_TOOLS_COMBINED ) + +# Both kifaces' full library sets (exported CACHE INTERNAL by pcbnew/ and +# eeschema/). The overlap (common, kicommon, gal, ...) is deduped. +set( KICAD_TOOLS_LIBS ${PCBNEW_KIFACE_LIBRARIES} ${EESCHEMA_KIFACE_LIBRARIES} ) +list( REMOVE_DUPLICATES KICAD_TOOLS_LIBS ) + +target_link_libraries( kicad_tools PRIVATE ${KICAD_TOOLS_LIBS} ) + +# Same link shape as pcb_convert: allow-multiple-definition for the wx/KiCad +# dupes and --whole-archive pcbcommon so RTTI-only-referenced vtables/typeinfo +# are pulled in. +target_link_options( kicad_tools PRIVATE + "LINKER:--allow-multiple-definition" + "LINKER:--whole-archive" + "$" + "LINKER:--no-whole-archive" + ) + +# Node CLI link shape — identical rationale to sym_convert (see +# eeschema/CMakeLists.txt): real FS, auto-run main(), synchronous, no embind, +# -g0 so wasm-emscripten-finalize doesn't OOM on DWARF, undefined symbols +# become aborting JS imports (both diets leave dangling refs from +# vtable-pinned-but-never-run editor code). The pre-js (shared with +# sym_convert) backs wxConfig with an in-memory store and copies process.env +# into the wasm ENV. +set_target_properties( kicad_tools PROPERTIES + LINK_FLAGS "-Oz -g0 --profiling-funcs -sSTACK_SIZE=2MB -sENVIRONMENT=node -sNODERAWFS=1 -sINVOKE_RUN=1 -sEXIT_RUNTIME=1 -sASYNCIFY=0 -sPTHREAD_POOL_SIZE=2 -sERROR_ON_UNDEFINED_SYMBOLS=0 --pre-js ${KICAD_WASM_LAYER}/cli/sym_convert_pre.js" )