app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schemati…
0009b41
14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768
//! Exports and manufacturing outputs that need no KiCad window: the kicad-cli argv for the
//! interchange formats (DXF, GenCAD, IPC-2581, ODB++, IPC-D-356), the BOM grouped from the
//! board's own footprints (generic and JLCPCB columns), the fab package README, a stored
//! (no compression) zip writer, and the board numbers a quote tool needs with a rough
//! JLCPCB-style cost table.
//!
//! The verb layer runs kicad-cli through `crate::cli::run`; everything here is pure so it
//! is unit-tested with canned inputs (kicad-cli is not needed to test it).
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use crate::model_check::{parse_sexpr, Sx};
// ---------------------------------------------------------------------------
// Interchange exports (kicad-cli pcb export <fmt>)
// ---------------------------------------------------------------------------
/// What one interchange export will run and where its output lands.
#[derive(Clone, Debug, PartialEq)]
pub struct ExportPlan {
/// kicad-cli subcommand: dxf, gencad, ipc2581, odb, ipcd356.
pub format: String,
/// Full argv after the kicad-cli executable.
pub argv: Vec<String>,
pub out_dir: PathBuf,
/// The single output file, or None for a per-layer directory export (DXF).
pub out_file: Option<PathBuf>,
/// File extension the export writes (".dxf", ".cad", ...), for the after-run listing.
pub ext: String,
}
fn s(p: &Path) -> String {
p.to_string_lossy().replace('\\', "/")
}
fn arg_s<'a>(args: &'a Value, k: &str) -> Option<&'a str> {
args.get(k).and_then(Value::as_str).map(str::trim).filter(|v| !v.is_empty())
}
fn arg_b(args: &Value, k: &str) -> bool {
args.get(k).and_then(Value::as_bool).unwrap_or(false)
}
/// `layers` as an array of names or one comma-separated string.
pub fn layer_list(v: Option<&Value>) -> Vec<String> {
match v {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),
Some(Value::String(t)) => t.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),
_ => Vec::new(),
}
}
fn bad(code: &str, msg: impl Into<String>, hint: impl Into<String>) -> Value {
json!({"success": false, "errorCode": code, "error": msg.into(), "_hint": hint.into()})
}
/// Build the kicad-cli argv for `format` on `board`. Errors are ready-to-return replies.
/// Options follow kicad-cli 9/10 (`kicad-cli pcb export <fmt> --help`).
pub fn plan_interchange(format: &str, board: &Path, args: &Value) -> Result<ExportPlan, Value> {
let stem = board.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "board".into());
let out_dir = arg_s(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| board.parent().unwrap_or(Path::new(".")).join(format!("{format}_output")));
let units = arg_s(args, "units").unwrap_or("mm");
if !["mm", "in"].contains(&units) {
return Err(bad("bad_arg", "units must be mm or in", "Pass units:\"mm\" (default) or units:\"in\"."));
}
let mut argv: Vec<String> = vec!["pcb".into(), "export".into(), format.into()];
let mut out_file: Option<PathBuf> = None;
let ext: &str;
match format {
"dxf" => {
let layers = layer_list(args.get("layers"));
if layers.is_empty() {
return Err(bad("missing_arg", "layers is required for DXF", "Pass layers, e.g. [\"Edge.Cuts\",\"F.Cu\"]: kicad-cli writes one .dxf per layer (named <board>-<layer>.dxf)."));
}
ext = ".dxf";
argv.push("--output".into());
argv.push(format!("{}/", s(&out_dir).trim_end_matches('/')));
argv.push("--layers".into());
argv.push(layers.join(","));
argv.push("--mode-multi".into());
argv.push("--output-units".into());
argv.push(units.into());
if arg_b(args, "useContours") {
argv.push("--use-contours".into());
}
if arg_b(args, "includeBorderTitle") {
argv.push("--include-border-title".into());
}
if arg_b(args, "excludeRefdes") {
argv.push("--exclude-refdes".into());
}
if arg_b(args, "excludeValue") {
argv.push("--exclude-value".into());
}
if arg_b(args, "useDrillOrigin") {
argv.push("--use-drill-origin".into());
}
}
"gencad" => {
ext = ".cad";
let f = out_dir.join(format!("{stem}.cad"));
argv.push("--output".into());
argv.push(s(&f));
for (k, flag) in [("flipBottomPads", "--flip-bottom-pads"), ("uniquePins", "--unique-pins"), ("uniqueFootprints", "--unique-footprints"), ("useDrillOrigin", "--use-drill-origin"), ("storeOriginCoord", "--store-origin-coord")] {
if arg_b(args, k) {
argv.push(flag.into());
}
}
out_file = Some(f);
}
"ipc2581" => {
let compress = arg_b(args, "compress");
ext = if compress { ".zip" } else { ".xml" };
let f = out_dir.join(format!("{stem}-ipc2581{ext}"));
argv.push("--output".into());
argv.push(s(&f));
argv.push("--units".into());
argv.push(units.into());
if let Some(p) = args.get("precision").and_then(Value::as_u64) {
argv.push("--precision".into());
argv.push(p.clamp(1, 9).to_string());
}
if let Some(v) = arg_s(args, "version") {
let v = v.to_uppercase();
if v != "B" && v != "C" {
return Err(bad("bad_arg", "version must be B or C", "IPC-2581 revision: \"C\" (KiCad's default) or \"B\"."));
}
argv.push("--version".into());
argv.push(v);
}
if compress {
argv.push("--compress".into());
}
for (k, flag) in [("bomColMfgPn", "--bom-col-mfg-pn"), ("bomColMfg", "--bom-col-mfg"), ("bomColDistPn", "--bom-col-dist-pn"), ("bomColDist", "--bom-col-dist"), ("bomColIntId", "--bom-col-int-id")] {
if let Some(v) = arg_s(args, k) {
argv.push(flag.into());
argv.push(v.into());
}
}
out_file = Some(f);
}
"odb" => {
let compression = arg_s(args, "compression").unwrap_or("zip");
if !["zip", "tgz", "none"].contains(&compression) {
return Err(bad("bad_arg", "compression must be zip, tgz or none", "ODB++ ships as a zip by default."));
}
ext = match compression { "zip" => ".zip", "tgz" => ".tgz", _ => "" };
let f = if compression == "none" { out_dir.join(format!("{stem}-odb")) } else { out_dir.join(format!("{stem}-odb{ext}")) };
argv.push("--output".into());
argv.push(s(&f));
argv.push("--units".into());
argv.push(units.into());
argv.push("--compression".into());
argv.push(compression.into());
if let Some(p) = args.get("precision").and_then(Value::as_u64) {
argv.push("--precision".into());
argv.push(p.clamp(1, 9).to_string());
}
out_file = Some(f);
}
"ipcd356" => {
ext = ".d356";
let f = out_dir.join(format!("{stem}.d356"));
argv.push("--output".into());
argv.push(s(&f));
out_file = Some(f);
}
other => return Err(bad("bad_arg", format!("unknown interchange format {other}"), "One of dxf, gencad, ipc2581, odb, ipcd356.")),
}
argv.push(s(board));
Ok(ExportPlan { format: format.into(), argv, out_dir, out_file, ext: ext.into() })
}
/// The reply for a finished export: `written` is what the caller found on disk afterwards
/// (name, bytes). A zero exit with nothing written is reported as a failure, not success.
pub fn export_reply(plan: &ExportPlan, exit_code: i32, timed_out: bool, stderr: &str, written: &[(String, u64)], label: &str) -> Value {
if exit_code != 0 || written.is_empty() {
let code = if timed_out { "cli_timeout" } else if exit_code == 0 { "no_output" } else { "cli_failed" };
let why = if stderr.trim().is_empty() { "no stderr".to_string() } else { stderr.trim().to_string() };
let mut hint = String::from("kicad-cli's stderr names the reason (file version, bad layer name, unknown option on an older kicad-cli).");
if stderr.contains("nrecognized") || stderr.contains("Unknown") {
hint = format!("This kicad-cli does not know an option the bridge passed (KiCad 9 or newer is needed for {}). kicad_list_versions shows the version.", plan.format);
}
return json!({"success": false, "errorCode": code, "error": format!("{label} export failed (exit {exit_code}): {why}"), "_hint": hint, "argv": plan.argv});
}
let files: Vec<Value> = written.iter().map(|(n, b)| json!({"name": n, "bytes": b})).collect();
let bytes: u64 = written.iter().map(|(_, b)| b).sum();
json!({
"success": true,
"output": format!("{label} exported: {} file(s), {bytes} bytes in {}", written.len(), s(&plan.out_dir)),
"data": {
"output_path": plan.out_file.as_ref().map(|f| s(f)).unwrap_or_else(|| s(&plan.out_dir)),
"outputDir": s(&plan.out_dir),
"files": files,
"bytes": bytes,
},
"format": plan.format,
"_hint": "Pull the files with ab pull_file. Run kicad_run_drc first: an interchange file carries the board's errors with it.",
})
}
/// Files in `dir` (one level) with `ext` (empty: any) modified at or after `since`.
pub fn files_written(dir: &Path, ext: &str, since: std::time::SystemTime) -> Vec<(String, u64)> {
let mut out: Vec<(String, u64)> = std::fs::read_dir(dir)
.map(|rd| {
rd.flatten()
.filter_map(|e| {
let m = e.metadata().ok()?;
let name = e.file_name().to_string_lossy().to_string();
let fresh = m.modified().map(|t| t >= since).unwrap_or(false);
let ext_ok = ext.is_empty() || name.to_lowercase().ends_with(&ext.to_lowercase());
(fresh && ext_ok).then_some((name, if m.is_file() { m.len() } else { dir_size(&e.path()) }))
})
.collect()
})
.unwrap_or_default();
out.sort();
out
}
fn dir_size(p: &Path) -> u64 {
std::fs::read_dir(p).map(|rd| rd.flatten().map(|e| { let q = e.path(); if q.is_dir() { dir_size(&q) } else { e.metadata().map(|m| m.len()).unwrap_or(0) } }).sum()).unwrap_or(0)
}
// ---------------------------------------------------------------------------
// Footprints as the BOM and the quote see them
// ---------------------------------------------------------------------------
/// One footprint with the fields the BOM, CPL and quote need.
#[derive(Clone, Debug, Default)]
pub struct Part {
pub reference: String,
pub value: String,
pub footprint: String,
pub layer: String,
pub props: BTreeMap<String, String>,
pub attrs: Vec<String>,
pub smd_pads: usize,
pub tht_pads: usize,
pub has_model: bool,
}
impl Part {
pub fn dnp(&self) -> bool {
self.attrs.iter().any(|a| a == "dnp")
}
pub fn in_bom(&self) -> bool {
!self.attrs.iter().any(|a| a == "exclude_from_bom" || a == "board_only") && !self.reference.starts_with('#')
}
pub fn is_smd(&self) -> bool {
self.attrs.iter().any(|a| a == "smd") || (self.tht_pads == 0 && self.smd_pads > 0 && !self.attrs.iter().any(|a| a == "through_hole"))
}
pub fn is_tht(&self) -> bool {
self.attrs.iter().any(|a| a == "through_hole") || (self.tht_pads > 0 && !self.attrs.iter().any(|a| a == "smd"))
}
/// The first non-empty property among `names` (case-insensitive).
pub fn field(&self, names: &[&str]) -> String {
for n in names {
if let Some((_, v)) = self.props.iter().find(|(k, v)| k.eq_ignore_ascii_case(n) && !v.trim().is_empty()) {
return v.trim().to_string();
}
}
String::new()
}
pub fn mpn(&self) -> String {
self.field(MPN_FIELDS)
}
pub fn lcsc(&self) -> String {
self.field(LCSC_FIELDS)
}
pub fn manufacturer(&self) -> String {
self.field(&["Manufacturer", "MFR", "Mfr", "Manufacturer_Name"])
}
}
pub const MPN_FIELDS: &[&str] = &["MPN", "Manufacturer Part Number", "Manufacturer_Part_Number", "MFR PN", "MFR_PN", "Mfr. Part #", "PartNumber", "Part Number"];
pub const LCSC_FIELDS: &[&str] = &["LCSC", "LCSC Part", "LCSC Part #", "LCSC_PN", "LCSC#", "JLCPCB Part", "JLCPCB Part #", "JLC", "JLCPCB"];
/// Every footprint of a board, from the board text.
pub fn parts_from_board(text: &str) -> Result<Vec<Part>, String> {
let tree = parse_sexpr(text).ok_or("empty board file")?;
if tree.tag() != Some("kicad_pcb") {
return Err("not a kicad_pcb file".into());
}
let mut out = Vec::new();
for fp in tree.find_all("footprint") {
let mut props = BTreeMap::new();
for p in fp.find_all("property") {
if let (Some(k), Some(v)) = (p.atom_at(1), p.atom_at(2)) {
props.insert(k.to_string(), v.to_string());
}
}
let attrs: Vec<String> = fp.find("attr").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default();
let mut smd = 0;
let mut tht = 0;
for pad in fp.find_all("pad") {
match pad.atom_at(2) {
Some("smd") => smd += 1,
Some("thru_hole") => tht += 1,
_ => {}
}
}
out.push(Part {
reference: props.get("Reference").cloned().unwrap_or_default(),
value: props.get("Value").cloned().unwrap_or_default(),
footprint: fp.atom_at(1).unwrap_or("").to_string(),
layer: fp.find("layer").and_then(Sx::value).unwrap_or("F.Cu").to_string(),
props,
attrs,
smd_pads: smd,
tht_pads: tht,
has_model: fp.find("model").is_some(),
});
}
Ok(out)
}
/// Natural order for references: R2 before R10.
pub fn ref_key(r: &str) -> (String, u64, String) {
let alpha: String = r.chars().take_while(|c| !c.is_ascii_digit()).collect();
let rest = &r[alpha.len()..];
let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
(alpha, digits.parse().unwrap_or(0), rest[digits.len()..].to_string())
}
/// One BOM line: the parts sharing value, footprint and MPN.
#[derive(Clone, Debug)]
pub struct BomLine {
pub refs: Vec<String>,
pub value: String,
pub footprint: String,
pub mpn: String,
pub manufacturer: String,
pub lcsc: String,
pub dnp: bool,
}
/// Group the board's parts into BOM lines (value + footprint + MPN), DNP parts on their own
/// lines, parts excluded from the BOM (or board-only) left out. Lines sort by first reference.
pub fn group_bom(parts: &[Part]) -> Vec<BomLine> {
let mut map: BTreeMap<(String, String, String, bool), BomLine> = BTreeMap::new();
for p in parts.iter().filter(|p| p.in_bom()) {
let key = (p.value.clone(), p.footprint.clone(), p.mpn(), p.dnp());
let line = map.entry(key).or_insert_with(|| BomLine { refs: Vec::new(), value: p.value.clone(), footprint: p.footprint.clone(), mpn: p.mpn(), manufacturer: String::new(), lcsc: String::new(), dnp: p.dnp() });
line.refs.push(p.reference.clone());
if line.lcsc.is_empty() {
line.lcsc = p.lcsc();
}
if line.manufacturer.is_empty() {
line.manufacturer = p.manufacturer();
}
}
let mut lines: Vec<BomLine> = map.into_values().collect();
for l in &mut lines {
l.refs.sort_by_key(|r| ref_key(r));
}
lines.sort_by(|a, b| ref_key(a.refs.first().map(String::as_str).unwrap_or("")).cmp(&ref_key(b.refs.first().map(String::as_str).unwrap_or(""))));
lines
}
fn csv_field(v: &str) -> String {
if v.contains(',') || v.contains('"') || v.contains('\n') {
format!("\"{}\"", v.replace('"', "\"\""))
} else {
v.to_string()
}
}
/// The footprint name without its library nickname (JLCPCB wants the package, not `Lib:`).
pub fn fp_name(lib_id: &str) -> &str {
lib_id.rsplit(':').next().unwrap_or(lib_id)
}
/// BOM CSV text. `fab` "jlcpcb" writes JLCPCB's assembly columns (Comment, Designator,
/// Footprint, LCSC Part #) and leaves DNP lines out; anything else the generic columns.
pub fn bom_csv(lines: &[BomLine], fab: &str) -> String {
let mut out = String::new();
if fab == "jlcpcb" {
out.push_str("Comment,Designator,Footprint,LCSC Part #\n");
for l in lines.iter().filter(|l| !l.dnp) {
out.push_str(&format!("{},{},{},{}\n", csv_field(&l.value), csv_field(&l.refs.join(",")), csv_field(fp_name(&l.footprint)), csv_field(&l.lcsc)));
}
} else {
out.push_str("Item,Qty,Reference(s),Value,Footprint,Manufacturer,MPN,LCSC,DNP\n");
for (i, l) in lines.iter().enumerate() {
out.push_str(&format!(
"{},{},{},{},{},{},{},{},{}\n",
i + 1,
l.refs.len(),
csv_field(&l.refs.join(",")),
csv_field(&l.value),
csv_field(&l.footprint),
csv_field(&l.manufacturer),
csv_field(&l.mpn),
csv_field(&l.lcsc),
if l.dnp { "DNP" } else { "" }
));
}
}
out
}
/// What a BOM leaves for a human: lines with no LCSC part (JLCPCB) or no MPN (generic).
pub fn bom_gaps(lines: &[BomLine], fab: &str) -> Vec<String> {
lines
.iter()
.filter(|l| !l.dnp)
.filter(|l| if fab == "jlcpcb" { l.lcsc.is_empty() } else { l.mpn.is_empty() })
.map(|l| format!("{} ({})", l.refs.join(","), l.value))
.collect()
}
// ---------------------------------------------------------------------------
// Fab package README and zip
// ---------------------------------------------------------------------------
/// README.txt for a fab package: what each file is, the board's headline numbers, the fab.
pub fn readme(board_name: &str, fab: &str, files: &[(String, String)], numbers: &Value, notes: &[String]) -> String {
let mut t = String::new();
t.push_str(&format!("Fabrication package for {board_name}\n"));
t.push_str(&format!("Prepared for: {}\n", if fab == "jlcpcb" { "JLCPCB (PCB + assembly columns)" } else { "any fab (generic columns)" }));
t.push_str("Generated by the Adom KiCad Bridge (kicad_export_fab_package) with kicad-cli.\n\n");
t.push_str("Board\n");
let g = |k: &str| numbers.get(k).map(|v| v.to_string().trim_matches('"').to_string()).unwrap_or_else(|| "?".into());
t.push_str(&format!(" Size: {} x {} mm\n Copper layers: {}\n Thickness: {} mm\n Parts: {} SMD, {} THT ({} unique)\n\n", g("widthMm"), g("heightMm"), g("layers"), g("thicknessMm"), g("smdParts"), g("thtParts"), g("uniqueParts")));
t.push_str("Files\n");
for (name, what) in files {
t.push_str(&format!(" {name}\n {what}\n"));
}
if !notes.is_empty() {
t.push_str("\nNotes\n");
for n in notes {
t.push_str(&format!(" - {n}\n"));
}
}
t
}
fn crc32(data: &[u8]) -> u32 {
static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new();
let table = TABLE.get_or_init(|| {
let mut t = [0u32; 256];
for (i, slot) in t.iter_mut().enumerate() {
let mut c = i as u32;
for _ in 0..8 {
c = if c & 1 != 0 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };
}
*slot = c;
}
t
});
let mut c = 0xFFFF_FFFFu32;
for &b in data {
c = table[((c ^ b as u32) & 0xff) as usize] ^ (c >> 8);
}
!c
}
/// A stored (method 0) zip of `(name in the archive, bytes)`. Gerbers are small and every
/// fab unpacks a stored zip, so no compressor is needed. Names use forward slashes.
pub fn zip_stored(files: &[(String, Vec<u8>)]) -> Result<Vec<u8>, String> {
if files.len() > 0xFFFF {
return Err("too many files for a zip without zip64".into());
}
let mut out = Vec::new();
let mut cd = Vec::new();
// 1980-01-01 00:00 in DOS time: deterministic archives.
let (dos_time, dos_date) = (0u16, (0u16 << 9) | (1 << 5) | 1);
for (name, data) in files {
if data.len() > u32::MAX as usize || out.len() > u32::MAX as usize {
return Err("file too large for a zip without zip64".into());
}
let name = name.replace('\\', "/");
let off = out.len() as u32;
let crc = crc32(data);
let n = name.as_bytes();
out.extend_from_slice(b"PK\x03\x04");
out.extend_from_slice(&20u16.to_le_bytes()); // version needed
out.extend_from_slice(&0x0800u16.to_le_bytes()); // UTF-8 names
out.extend_from_slice(&0u16.to_le_bytes()); // stored
out.extend_from_slice(&dos_time.to_le_bytes());
out.extend_from_slice(&dos_date.to_le_bytes());
out.extend_from_slice(&crc.to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out.extend_from_slice(&(data.len() as u32).to_le_bytes());
out.extend_from_slice(&(n.len() as u16).to_le_bytes());
out.extend_from_slice(&0u16.to_le_bytes());
out.extend_from_slice(n);
out.extend_from_slice(data);
cd.extend_from_slice(b"PK\x01\x02");
cd.extend_from_slice(&20u16.to_le_bytes()); // made by
cd.extend_from_slice(&20u16.to_le_bytes()); // needed
cd.extend_from_slice(&0x0800u16.to_le_bytes());
cd.extend_from_slice(&0u16.to_le_bytes());
cd.extend_from_slice(&dos_time.to_le_bytes());
cd.extend_from_slice(&dos_date.to_le_bytes());
cd.extend_from_slice(&crc.to_le_bytes());
cd.extend_from_slice(&(data.len() as u32).to_le_bytes());
cd.extend_from_slice(&(data.len() as u32).to_le_bytes());
cd.extend_from_slice(&(n.len() as u16).to_le_bytes());
cd.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // extra, comment, disk, int attr, ext attr
cd.extend_from_slice(&off.to_le_bytes());
cd.extend_from_slice(n);
}
let cd_off = out.len() as u32;
let cd_len = cd.len() as u32;
out.extend_from_slice(&cd);
out.extend_from_slice(b"PK\x05\x06");
out.extend_from_slice(&[0, 0, 0, 0]);
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
out.extend_from_slice(&(files.len() as u16).to_le_bytes());
out.extend_from_slice(&cd_len.to_le_bytes());
out.extend_from_slice(&cd_off.to_le_bytes());
out.extend_from_slice(&[0, 0]);
Ok(out)
}
/// Every file under `root` as (path relative to root with `/`, bytes), sorted.
pub fn collect_tree(root: &Path) -> Vec<(String, Vec<u8>)> {
fn walk(base: &Path, dir: &Path, out: &mut Vec<(String, Vec<u8>)>) {
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let p = e.path();
if p.is_dir() {
walk(base, &p, out);
} else if let Ok(bytes) = std::fs::read(&p) {
let rel = p.strip_prefix(base).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();
out.push((rel, bytes));
}
}
}
let mut out = Vec::new();
walk(root, root, &mut out);
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
// ---------------------------------------------------------------------------
// Board numbers and a rough cost table
// ---------------------------------------------------------------------------
/// The numbers a PCB quote needs, from the board file and `crate::dfm`'s geometry.
pub fn board_numbers(text: &str) -> Result<Value, String> {
let g = crate::dfm::Geometry::from_text(text)?;
let parts = parts_from_board(text)?;
let bom = group_bom(&parts);
let placed: Vec<&Part> = parts.iter().filter(|p| p.in_bom() && !p.dnp()).collect();
let smd = placed.iter().filter(|p| p.is_smd()).count();
let tht = placed.iter().filter(|p| p.is_tht() && !p.is_smd()).count();
let sides: std::collections::BTreeSet<&str> = placed.iter().map(|p| if p.layer.starts_with('B') { "bottom" } else { "top" }).collect();
let smd_joints: usize = placed.iter().map(|p| p.smd_pads).sum();
let tht_joints: usize = placed.iter().map(|p| p.tht_pads).sum();
let (w, h) = g.outline_size().unwrap_or((0.0, 0.0));
let mut drills: Vec<f64> = g.holes.iter().map(|h| r3(h.drill)).collect();
drills.sort_by(|a, b| a.partial_cmp(b).unwrap());
drills.dedup();
let unique = bom.iter().filter(|l| !l.dnp).count();
Ok(json!({
"widthMm": r3(w), "heightMm": r3(h), "areaCm2": r3(w * h / 100.0),
"layers": g.copper_layers.len(), "thicknessMm": g.thickness,
"minTrackMm": g.min_track().map(r3), "minSpaceMm": g.min_space().map(|x| r3(x.0)),
"minDrillMm": drills.first().copied(), "holes": g.holes.len(),
"viaCount": g.holes.iter().filter(|h| h.via).count(), "drillSizes": drills,
"smdParts": smd, "thtParts": tht, "uniqueParts": unique, "totalParts": placed.len(),
"smdJoints": smd_joints, "thtJoints": tht_joints,
"assemblySides": sides.into_iter().collect::<Vec<_>>(),
"outlineClosed": g.outline_closed().0,
}))
}
fn r3(v: f64) -> f64 {
(v * 1000.0).round() / 1000.0
}
fn r2(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
/// A rough JLCPCB-style price table from `board_numbers`. Every rate is an assumption and
/// the reply says so: this is a sanity check on order of magnitude, not a quote.
pub fn cost_table(n: &Value, quantities: &[u64], assembly: bool, basic_parts: Option<u64>) -> Value {
let layers = n["layers"].as_u64().unwrap_or(2).max(1);
let w = n["widthMm"].as_f64().unwrap_or(0.0);
let h = n["heightMm"].as_f64().unwrap_or(0.0);
let area_m2 = w * h / 1_000_000.0;
// (setup USD, USD per m2 of boards) by copper count.
let (setup, per_m2) = match layers {
1 | 2 => (2.0, 30.0),
3 | 4 => (8.0, 90.0),
5 | 6 => (40.0, 200.0),
_ => (80.0, 350.0),
};
let small_2l = layers <= 2 && w <= 100.0 && h <= 100.0;
let thick = n["thicknessMm"].as_f64().unwrap_or(1.6);
let thick_extra = if (thick - 1.6).abs() > 0.05 { 1.15 } else { 1.0 };
let unique = n["uniqueParts"].as_u64().unwrap_or(0);
let extended = basic_parts.map(|b| unique.saturating_sub(b)).unwrap_or(unique);
let sides = n["assemblySides"].as_array().map(|a| a.len()).unwrap_or(1).max(1) as f64;
let smd_j = n["smdJoints"].as_u64().unwrap_or(0) as f64;
let tht_j = n["thtJoints"].as_u64().unwrap_or(0) as f64;
let rows: Vec<Value> = quantities
.iter()
.map(|&q| {
let qf = q as f64;
let mut pcb = setup + per_m2 * area_m2 * qf;
if small_2l && q <= 10 {
pcb = if q <= 5 { 2.0 } else { 5.0 };
}
pcb = r2(pcb * thick_extra);
let mut row = json!({"quantity": q, "pcbUsd": pcb});
if assembly && n["totalParts"].as_u64().unwrap_or(0) > 0 {
let setup_fee = 8.0 * sides;
let stencil = 7.0 * sides;
let joints = (smd_j * 0.0017 + tht_j * 0.0173) * qf;
let loading = 3.0 * extended as f64;
let asm = r2(setup_fee + stencil + joints + loading);
row["assemblyUsd"] = json!(asm);
row["totalUsdExcludingParts"] = json!(r2(pcb + asm));
} else {
row["totalUsdExcludingParts"] = json!(pcb);
}
row["perBoardUsd"] = json!(r2(row["totalUsdExcludingParts"].as_f64().unwrap_or(0.0) / qf.max(1.0)));
row
})
.collect();
let mut assumptions = vec![
format!("PCB: {setup} USD setup plus {per_m2} USD per square metre of boards for {layers} copper layer(s), FR-4, HASL, green mask, standard tolerances."),
"2-layer boards up to 100 x 100 mm: 2 USD for 5 and 5 USD for 10 (the promotional price).".to_string(),
"Thickness other than 1.6 mm adds 15 percent.".to_string(),
"Shipping, tax, customs, colour, finish (ENIG), impedance control and panelisation are not included.".to_string(),
];
if assembly {
assumptions.push(format!("Assembly: 8 USD setup and 7 USD stencil per assembled side ({sides} side(s)), 0.0017 USD per SMD joint, 0.0173 USD per THT joint, 3 USD loading fee per extended part ({extended} of {unique} unique parts counted as extended{}).", if basic_parts.is_some() { "" } else { "; pass basicParts to count the basic ones" }));
assumptions.push("Component cost is NOT included: price the BOM with adom-parts-search or adom-instapcb-quote.".to_string());
}
json!({"currency": "USD", "rows": rows, "assumptions": assumptions, "rough": true})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dxf_requires_layers_and_writes_one_file_per_layer() {
let b = Path::new("C:/p/board.kicad_pcb");
let e = plan_interchange("dxf", b, &json!({})).unwrap_err();
assert_eq!(e["errorCode"], "missing_arg");
let p = plan_interchange("dxf", b, &json!({"layers": "Edge.Cuts, F.Cu", "units": "in", "useContours": true})).unwrap();
assert_eq!(p.argv, vec!["pcb", "export", "dxf", "--output", "C:/p/dxf_output/", "--layers", "Edge.Cuts,F.Cu", "--mode-multi", "--output-units", "in", "--use-contours", "C:/p/board.kicad_pcb"]);
assert!(p.out_file.is_none());
}
#[test]
fn gencad_ipc2581_odb_ipcd356_argv() {
let b = Path::new("/w/x.kicad_pcb");
let g = plan_interchange("gencad", b, &json!({"outputDir": "/o", "uniquePins": true})).unwrap();
assert_eq!(g.argv, vec!["pcb", "export", "gencad", "--output", "/o/x.cad", "--unique-pins", "/w/x.kicad_pcb"]);
let i = plan_interchange("ipc2581", b, &json!({"outputDir": "/o", "compress": true, "version": "b", "bomColMfgPn": "MPN", "precision": 4})).unwrap();
assert_eq!(i.argv, vec!["pcb", "export", "ipc2581", "--output", "/o/x-ipc2581.zip", "--units", "mm", "--precision", "4", "--version", "B", "--compress", "--bom-col-mfg-pn", "MPN", "/w/x.kicad_pcb"]);
assert!(plan_interchange("ipc2581", b, &json!({"version": "D"})).is_err());
let o = plan_interchange("odb", b, &json!({"outputDir": "/o"})).unwrap();
assert_eq!(o.argv, vec!["pcb", "export", "odb", "--output", "/o/x-odb.zip", "--units", "mm", "--compression", "zip", "/w/x.kicad_pcb"]);
assert!(plan_interchange("odb", b, &json!({"compression": "rar"})).is_err());
let d = plan_interchange("ipcd356", b, &json!({"outputDir": "/o"})).unwrap();
assert_eq!(d.argv, vec!["pcb", "export", "ipcd356", "--output", "/o/x.d356", "/w/x.kicad_pcb"]);
assert!(plan_interchange("dxf", b, &json!({"layers": ["F.Cu"], "units": "cm"})).is_err());
}
#[test]
fn export_reply_is_honest_about_empty_output_and_old_cli() {
let p = plan_interchange("ipcd356", Path::new("/w/x.kicad_pcb"), &json!({"outputDir": "/o"})).unwrap();
let ok = export_reply(&p, 0, false, "", &[("x.d356".into(), 1200)], "IPC-D-356");
assert_eq!(ok["success"], true);
assert_eq!(ok["data"]["files"][0]["bytes"], 1200);
let none = export_reply(&p, 0, false, "", &[], "IPC-D-356");
assert_eq!(none["errorCode"], "no_output");
let old = export_reply(&p, 1, false, "Unknown argument: ipcd356", &[], "IPC-D-356");
assert_eq!(old["errorCode"], "cli_failed");
assert!(old["_hint"].as_str().unwrap().contains("KiCad 9 or newer"));
let to = export_reply(&p, -2, true, "kicad-cli timed out", &[], "IPC-D-356");
assert_eq!(to["errorCode"], "cli_timeout");
}
const BOARD: &str = r#"(kicad_pcb (version 20260206) (generator "pcbnew")
(general (thickness 1.6))
(layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))
(footprint "Resistor_SMD:R_0603_1608Metric" (layer "F.Cu") (at 10 10)
(property "Reference" "R10") (property "Value" "10k") (property "LCSC" "C25804") (property "MPN" "RC0603FR-0710KL")
(attr smd) (pad "1" smd roundrect (at -0.8 0) (size 0.8 0.9) (layers "F.Cu")) (pad "2" smd roundrect (at 0.8 0) (size 0.8 0.9) (layers "F.Cu"))
(model "${KICAD10_3DMODEL_DIR}/R.step"))
(footprint "Resistor_SMD:R_0603_1608Metric" (layer "F.Cu") (at 14 10)
(property "Reference" "R2") (property "Value" "10k") (property "LCSC" "C25804") (property "MPN" "RC0603FR-0710KL")
(attr smd) (pad "1" smd roundrect (at -0.8 0) (size 0.8 0.9) (layers "F.Cu")) (pad "2" smd roundrect (at 0.8 0) (size 0.8 0.9) (layers "F.Cu")))
(footprint "Resistor_SMD:R_0603_1608Metric" (layer "B.Cu") (at 18 10)
(property "Reference" "R3") (property "Value" "1k")
(attr smd dnp) (pad "1" smd roundrect (at -0.8 0) (size 0.8 0.9) (layers "B.Cu")) (pad "2" smd roundrect (at 0.8 0) (size 0.8 0.9) (layers "B.Cu")))
(footprint "Connector:Pin_1x02" (layer "F.Cu") (at 30 10)
(property "Reference" "J1") (property "Value" "Conn, 2 pin")
(attr through_hole) (pad "1" thru_hole rect (at 0 0) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask")) (pad "2" thru_hole oval (at 2.54 0) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask")))
(footprint "Logo:Adom" (layer "F.Cu") (at 40 10) (property "Reference" "G1") (property "Value" "LOGO") (attr board_only exclude_from_pos_files exclude_from_bom))
(gr_rect (start 0 0) (end 50 20) (layer "Edge.Cuts"))
(segment (start 10.8 10) (end 13.2 10) (width 0.25) (layer "F.Cu") (net "N1"))
(via (at 20 15) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "N1"))
)"#;
#[test]
fn bom_groups_by_value_footprint_and_mpn_in_natural_order() {
let parts = parts_from_board(BOARD).unwrap();
assert_eq!(parts.len(), 5);
let lines = group_bom(&parts);
assert_eq!(lines.len(), 3, "R2+R10, R3 (dnp), J1; G1 is board-only");
assert_eq!(lines[0].refs, vec!["J1"]);
assert_eq!(lines[1].refs, vec!["R2", "R10"]);
assert_eq!(lines[1].lcsc, "C25804");
assert!(lines[2].dnp);
let generic = bom_csv(&lines, "generic");
assert!(generic.starts_with("Item,Qty,Reference(s),Value,Footprint,Manufacturer,MPN,LCSC,DNP\n"));
assert!(generic.contains("1,1,J1,\"Conn, 2 pin\",Connector:Pin_1x02,,,,\n"), "{generic}");
assert!(generic.contains("2,2,\"R2,R10\",10k,Resistor_SMD:R_0603_1608Metric,,RC0603FR-0710KL,C25804,\n"), "{generic}");
assert!(generic.contains(",DNP\n"));
let jlc = bom_csv(&lines, "jlcpcb");
assert_eq!(jlc, "Comment,Designator,Footprint,LCSC Part #\n\"Conn, 2 pin\",J1,Pin_1x02,\n10k,\"R2,R10\",R_0603_1608Metric,C25804\n");
assert_eq!(bom_gaps(&lines, "jlcpcb"), vec!["J1 (Conn, 2 pin)"]);
}
#[test]
fn stored_zip_reads_back_with_the_bridge_reader() {
let files = vec![("gerbers/x-F_Cu.gbr".to_string(), b"G04 test*\n".to_vec()), ("README.txt".to_string(), b"hello".to_vec())];
let z = zip_stored(&files).unwrap();
let zf = crate::pcm::ZipFile::parse(&z).unwrap();
let names: Vec<&str> = zf.entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, vec!["gerbers/x-F_Cu.gbr", "README.txt"]);
assert_eq!(crc32(b"123456789"), 0xCBF4_3926, "standard CRC-32 check value");
}
#[test]
fn readme_lists_every_file() {
let n = json!({"widthMm": 50.0, "heightMm": 20.0, "layers": 2, "thicknessMm": 1.6, "smdParts": 2, "thtParts": 1, "uniqueParts": 2});
let t = readme("board", "jlcpcb", &[("gerbers/".into(), "Gerber X2 and drill".into()), ("bom/board_BOM_JLCPCB.csv".into(), "BOM".into())], &n, &["2 BOM lines have no LCSC part".into()]);
assert!(t.contains("Size: 50.0 x 20.0 mm"));
assert!(t.contains("bom/board_BOM_JLCPCB.csv"));
assert!(t.contains("- 2 BOM lines have no LCSC part"));
assert!(!t.contains('\u{2014}'), "no em-dashes");
}
#[test]
fn numbers_and_cost_table() {
let n = board_numbers(BOARD).unwrap();
assert_eq!(n["widthMm"], 50.0);
assert_eq!(n["heightMm"], 20.0);
assert_eq!(n["layers"], 2);
assert_eq!(n["smdParts"], 2, "R3 is DNP, G1 board only");
assert_eq!(n["thtParts"], 1);
assert_eq!(n["uniqueParts"], 2);
assert_eq!(n["holes"], 3, "two THT pads and one via");
assert_eq!(n["minDrillMm"], 0.3);
assert_eq!(n["minTrackMm"], 0.25);
assert_eq!(n["outlineClosed"], true);
let t = cost_table(&n, &[5, 100], true, Some(1));
assert_eq!(t["rows"][0]["pcbUsd"], 2.0);
assert!(t["rows"][1]["pcbUsd"].as_f64().unwrap() > 2.0);
assert!(t["rows"][0]["assemblyUsd"].as_f64().unwrap() >= 18.0, "setup + stencil + one extended part");
assert!(t["assumptions"].as_array().unwrap().iter().any(|a| a.as_str().unwrap().contains("NOT included")));
}
}