plugins 0002: kicad_tools --ipc356 + --fab-components fab exporters

Two generic (manufacturer-agnostic) pcbnew-side subcommands for platform
manufacturing plugins: --ipc356 (IPC-D-356 netlist via IPC356D_WRITER) and
--fab-components (board metrics + per-footprint placement/BOM JSON —
absolute board coords, consumers apply their own conventions). Bumps the
kicad submodule for the diet keep-back of export_d356.cpp.

Also closes a CI cache gap: wasm-cache-hash never hashed wasm/**, so a
pure wasm/cli change would cache-hit stale output — { dir: "wasm" } is
now an input (busts the cache once on landing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mr8PQ34yCfvEtEcwSUvLAV
This commit is contained in:
Gergő Törcsvári 2026-07-24 14:30:08 +02:00
commit f1ea6b3965
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
4 changed files with 229 additions and 3 deletions

2
kicad

@ -1 +1 @@
Subproject commit d06f653f0edc18b1f7cc6e52d0bb3be426a5cefa
Subproject commit ca1dde0e5ffdd9fa32062d816c59a301d1d0fc6c

View file

@ -59,6 +59,13 @@ const INPUTS = [
// Per-tool compile recipes (compile flags / emcc link options).
{ dir: "scripts/kicad", match: /^build-.*\.sh$/ },
// The pcbjam-repo C++/CMake/shim sources compiled INTO the tools (bindings,
// cli mains, kiplatform, gl1, stubs, editor glue). These live outside the
// kicad/wx submodules, so without this entry a pure wasm/** edit (e.g. a
// new kicad_tools subcommand) would cache-hit stale output — plugins 0002
// shipped exactly that way before this entry existed.
{ dir: "wasm" },
// Dependency builds (boost/cairo/occ/... — the sysroot the wasm links against).
{ dir: "scripts/deps" },

View file

@ -10,6 +10,8 @@
* kicad_tools --gerbers <file.kicad_pcb> [<outdir>]
* kicad_tools --drill <file.kicad_pcb> [<outdir>]
* kicad_tools --plot-board [--pdf] [--layers <a,b,...>] <file.kicad_pcb> [<out>]
* kicad_tools --ipc356 <file.kicad_pcb> [<out.ipc>]
* kicad_tools --fab-components <file.kicad_pcb> [<out.json>]
*
* eeschema side (sym_convert_main.cpp) everything else:
* kicad_tools --convert-lib <input.lib> <output.kicad_sym>
@ -47,7 +49,9 @@ int main( int argc, char** argv )
if( argc >= 2
&& ( std::strcmp( argv[1], "--drc" ) == 0 || std::strcmp( argv[1], "--gerbers" ) == 0
|| std::strcmp( argv[1], "--drill" ) == 0
|| std::strcmp( argv[1], "--plot-board" ) == 0 ) )
|| std::strcmp( argv[1], "--plot-board" ) == 0
|| std::strcmp( argv[1], "--ipc356" ) == 0
|| std::strcmp( argv[1], "--fab-components" ) == 0 ) )
{
rc = pcbConvertMain( argc, argv );
}

View file

@ -32,6 +32,20 @@
* default to the board's enabled set, or a comma-separated list
* of canonical/user layer names. Exit: 0 ok, 2 usage, 4 failure.
*
* ipc356: pcb_convert --ipc356 <file.kicad_pcb> [<out.ipc>]
* IPC-D-356 netlist via IPC356D_WRITER, default settings (out
* defaults to <file>-netlist.ipc). Exit: 0 ok, 2 usage, 4 load
* failure, 5 write failure.
*
* fab: pcb_convert --fab-components <file.kicad_pcb> [<out.json>]
* Manufacturer-agnostic fabrication data JSON (plugins 0002):
* board metrics (Edge.Cuts bbox mm, aux origin, copper layer
* count, thickness) + one entry per footprint (ref, fpid, side,
* absolute position mm in board coords no Y flip, no
* aux-origin subtraction; consumers derive their own rotation
* deg, smd/exclude/dnp attributes, all non-empty fields).
* Exit: 0 ok, 2 usage, 4 load failure, 5 write 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).
@ -43,6 +57,7 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include <memory>
#include <string>
#include <unistd.h>
@ -54,13 +69,16 @@
#include <wx/string.h>
#include <base_screen.h>
#include <base_units.h>
#include <board.h>
#include <board_design_settings.h>
#include <drc/drc_engine.h>
#include <drc/drc_item.h>
#include <drc/drc_report.h>
#include <exporters/export_d356.h>
#include <exporters/gendrill_excellon_writer.h>
#include <exporters/gerber_jobfile_writer.h>
#include <json_common.h>
#include <jobs/job_export_pcb_drill.h>
#include <jobs/job_export_pcb_gerbers.h>
#include <jobs/job_export_pcb_pdf.h>
@ -70,6 +88,7 @@
#include <lset.h>
#include <libraries/library_manager.h>
#include <footprint.h>
#include <pcb_field.h>
#include <pcb_io/kicad_sexpr/pcb_io_kicad_sexpr.h>
#include <pcb_io/pcb_io_mgr.h>
#include <pcb_marker.h>
@ -636,6 +655,147 @@ int runDrill( const char* aInPath, const char* aOutDir )
return ok ? 0 : 4;
}
// ── IPC-D-356 netlist export ──────────────────────────────────────────────────
// Same IPC356D_WRITER call the pcbnew GUI export makes, default settings; net
// assignments come from the saved board (no connectivity rebuild needed).
int runIpc356( const char* aInPath, const char* aOutPath )
{
wxFileName fn( wxString::FromUTF8( aInPath ) );
fn.MakeAbsolute();
BOARD* brd = loadBoardHeadless( aInPath );
if( !brd )
return 4;
wxString outPath;
if( aOutPath )
{
outPath = wxString::FromUTF8( aOutPath );
}
else
{
wxFileName out( fn );
out.SetName( out.GetName() + wxS( "-netlist" ) );
out.SetExt( wxS( "ipc" ) );
outPath = out.GetFullPath();
}
trace( "runIpc356: IPC356D_WRITER::Write" );
IPC356D_WRITER writer( brd );
const bool ok = writer.Write( outPath );
std::fprintf( stderr, "%s: %s (ipc-356d) -> %s\n", aInPath, ok ? "OK" : "FAIL",
(const char*) outPath.ToUTF8() );
return ok ? 0 : 5;
}
// ── fabrication components JSON ───────────────────────────────────────────────
// Manufacturer-agnostic per-footprint placement/BOM data + board metrics
// (plugins 0002). Coordinates are absolute board coords in mm, as-is: no Y
// flip, no aux-origin subtraction — manufacturer-specific conventions (e.g.
// PCBWay's aux-relative negated-Y positions) are applied by the consumer.
int runFabComponents( const char* aInPath, const char* aOutPath )
{
wxFileName fn( wxString::FromUTF8( aInPath ) );
fn.MakeAbsolute();
BOARD* brd = loadBoardHeadless( aInPath );
if( !brd )
return 4;
wxString outPath;
if( aOutPath )
{
outPath = wxString::FromUTF8( aOutPath );
}
else
{
wxFileName out( fn );
out.SetName( out.GetName() + wxS( "-fab-components" ) );
out.SetExt( wxS( "json" ) );
outPath = out.GetFullPath();
}
trace( "runFabComponents: collecting" );
const BOX2I bbox = brd->GetBoardEdgesBoundingBox();
const VECTOR2I aux = brd->GetDesignSettings().GetAuxOrigin();
nlohmann::json board;
board["widthMm"] = pcbIUScale.IUTomm( bbox.GetWidth() );
board["heightMm"] = pcbIUScale.IUTomm( bbox.GetHeight() );
board["bboxOriginMm"] = { { "x", pcbIUScale.IUTomm( bbox.GetX() ) },
{ "y", pcbIUScale.IUTomm( bbox.GetY() ) } };
board["auxOriginMm"] = { { "x", pcbIUScale.IUTomm( aux.x ) },
{ "y", pcbIUScale.IUTomm( aux.y ) } };
board["copperLayers"] = brd->GetCopperLayerCount();
board["thicknessMm"] = pcbIUScale.IUTomm( brd->GetDesignSettings().GetBoardThickness() );
nlohmann::json footprints = nlohmann::json::array();
for( FOOTPRINT* fp : brd->Footprints() )
{
const VECTOR2I pos = fp->GetPosition();
const int attrs = fp->GetAttributes();
nlohmann::json f;
f["ref"] = std::string( fp->GetReference().ToUTF8() );
f["fpid"] = std::string( fp->GetFPID().GetUniStringLibId().ToUTF8() );
f["side"] = fp->GetLayer() == B_Cu ? "bottom" : "top";
f["posMm"] = { { "x", pcbIUScale.IUTomm( pos.x ) },
{ "y", pcbIUScale.IUTomm( pos.y ) } };
f["rotationDeg"] = fp->GetOrientationDegrees();
f["smd"] = bool( attrs & FP_SMD );
f["excludeFromPos"] = fp->IsExcludedFromPosFiles();
f["excludeFromBom"] = fp->IsExcludedFromBOM();
f["dnp"] = fp->IsDNP();
nlohmann::json fields = nlohmann::json::object();
std::vector<PCB_FIELD*> fieldList;
fp->GetFields( fieldList, false );
for( PCB_FIELD* field : fieldList )
{
const wxString text = field->GetShownText( false );
if( !text.IsEmpty() )
fields[std::string( field->GetName().ToUTF8() )] = std::string( text.ToUTF8() );
}
f["fields"] = fields;
footprints.push_back( f );
}
nlohmann::json j;
j["board"] = board;
j["footprints"] = footprints;
trace( "runFabComponents: writing" );
std::ofstream out( outPath.fn_str() );
out << j.dump( 2 ) << "\n";
out.close();
if( !out )
{
std::fprintf( stderr, "%s: error: unable to write %s\n", aInPath,
(const char*) outPath.ToUTF8() );
return 5;
}
std::fprintf( stderr, "%s: OK (%d footprints) -> %s\n", aInPath,
(int) brd->Footprints().size(), (const char*) outPath.ToUTF8() );
return 0;
}
} // namespace
@ -1035,10 +1195,65 @@ int pcbConvertMain( int argc, char** argv )
_exit( rc );
}
if( argc >= 2 && std::strcmp( argv[1], "--ipc356" ) == 0 )
{
wxDisableAsserts();
if( argc < 3 )
{
std::fprintf( stderr, "usage: kicad_tools --ipc356 <file.kicad_pcb> [<out.ipc>]\n" );
return 2;
}
int rc = 4;
try
{
rc = runIpc356( argv[2], argc >= 4 ? argv[3] : nullptr );
}
catch( const std::exception& e )
{
std::fprintf( stderr, "%s: error: %s\n", argv[2], e.what() );
}
// Same static-dtor rationale as --drc above.
std::fflush( nullptr );
_exit( rc );
}
if( argc >= 2 && std::strcmp( argv[1], "--fab-components" ) == 0 )
{
wxDisableAsserts();
if( argc < 3 )
{
std::fprintf( stderr,
"usage: kicad_tools --fab-components <file.kicad_pcb> [<out.json>]\n" );
return 2;
}
int rc = 4;
try
{
rc = runFabComponents( argv[2], argc >= 4 ? argv[3] : nullptr );
}
catch( const std::exception& e )
{
std::fprintf( stderr, "%s: error: %s\n", argv[2], e.what() );
}
// Same static-dtor rationale as --drc above.
std::fflush( nullptr );
_exit( rc );
}
std::fprintf( stderr, "usage: kicad_tools --drc [--json] [--strict] <file.kicad_pcb> [<out>]\n"
" kicad_tools --gerbers <file.kicad_pcb> [<outdir>]\n"
" kicad_tools --drill <file.kicad_pcb> [<outdir>]\n"
" kicad_tools --plot-board [--pdf] [--layers <a,b,...>] <file.kicad_pcb> [<out>]\n" );
" kicad_tools --plot-board [--pdf] [--layers <a,b,...>] <file.kicad_pcb> [<out>]\n"
" kicad_tools --ipc356 <file.kicad_pcb> [<out.ipc>]\n"
" kicad_tools --fab-components <file.kicad_pcb> [<out.json>]\n" );
return 2;
}