app
AI Flow: JLCPCB
Public Made by Adomby adom
The whole JLCPCB order, from gerbers to assembled boards in hand: proven part swaps, design rules, rotation checks, quote and order.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
//! The manufacturing package JLCPCB's upload page takes, made by the desktop's native kicad-cli (KiCad 10) through
//! Adom Bridge, because the shared KiCad service exports no paste layers and no placement file:
//! gerbers F/B.Cu, F/B.Paste, F/B.Silkscreen, F/B.Mask, Edge.Cuts (plus inner copper), Protel names
//! drill one Excellon file, mm, decimal, absolute origin
//! pos placement CSV, both sides, mm
//! Then, with JLCPCB's own column names:
//! <board>-gerbers.zip what "Add gerber file" takes
//! <board>-bom-jlcpcb.csv Comment, Designator, Footprint, LCSC Part # (one row per LCSC number)
//! <board>-cpl-jlcpcb.csv Designator, Mid X, Mid Y, Layer, Rotation
//! <board>-fab-manifest.json sha256 of every file, the parts left out and why, the rotations applied
//! Parts whose BOM row says fit=post (contacts, machine pins, hand-fitted parts) stay out of the BOM and CPL; every
//! other placed reference must have an LCSC number, or the export refuses.
use crate::bridge::Bridge;
use crate::csvio::{self, Table};
use crate::kicad::{g, ref_key};
use regex::Regex;
use serde_json::{json, Map, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
pub struct Package { pub manifest: PathBuf, pub cpl: PathBuf, pub base: String, pub summary: String }
fn out_text(r: &Value) -> String {
format!("{}{}", Bridge::field(r, "stdout").and_then(|v| v.as_str()).unwrap_or(""), Bridge::field(r, "stderr").and_then(|v| v.as_str()).unwrap_or(""))
}
pub fn export(br: &Bridge, board: &Path, bom_path: &Path, out: &Path, rotations: &Map<String, Value>) -> Result<Package, String> {
let board = board.canonicalize().map_err(|e| format!("{}: {e}", board.display()))?;
let base = board.file_stem().and_then(|s| s.to_str()).unwrap_or("board").to_string();
std::fs::create_dir_all(out).map_err(|e| e.to_string())?;
let st = br.call("kicad_status", &json!({"reason": "find the native kicad-cli for the JLCPCB export"}));
let cli = Bridge::field(&st, "install").and_then(|i| i["kicadCli"].as_str()).map(str::to_string)
.or_else(|| std::env::var("KICAD_REMOTE_CLI").ok()).ok_or(format!("no KiCad on {} (set KICAD_REMOTE_CLI)", br.target))?;
let sh = |cmd: &str, why: &str| br.call("shell_execute", &json!({"command": cmd, "timeoutSeconds": 240, "reason": why}));
let tmpw = out_text(&sh("echo %TEMP%", "temp folder for the JLCPCB export")).trim().replace('\\', "/");
if tmpw.is_empty() { return Err(format!("the desktop {} did not answer (is it connected?)", br.target)); }
let dest = format!("{tmpw}/aiflow-jlcpcb-{}", crate::ledger::now().replace([':', '-'], ""));
let dir = board.parent().unwrap();
let mut send = vec![board.display().to_string()];
for e in std::fs::read_dir(dir).map_err(|e| e.to_string())?.flatten() {
let n = e.file_name().to_string_lossy().to_string();
if n == format!("{base}.kicad_pro") || n.ends_with(".kicad_dru") { send.push(e.path().display().to_string()); }
}
br.call("send_files", &json!({"filePaths": send, "dest": dest, "reason": "JLCPCB export (native kicad-cli on the desktop)"}));
let wb = format!("{dest}/{base}.kicad_pcb");
let gdir = format!("{dest}/gerbers");
let text = std::fs::read_to_string(&board).map_err(|e| e.to_string())?;
let inner: BTreeSet<String> = Regex::new(r#"\(\d+ "(In\d+\.Cu)""#).unwrap().captures_iter(&text).map(|c| c[1].to_string()).collect();
let mut layers = vec!["F.Cu".to_string(), "B.Cu".into()];
layers.extend((1..=inner.len()).map(|i| format!("In{i}.Cu")));
layers.extend(["F.Paste", "B.Paste", "F.Silkscreen", "B.Silkscreen", "F.Mask", "B.Mask", "Edge.Cuts"].map(String::from));
let q = |p: &str| format!("\"{}\"", p.replace('/', "\\"));
let cmds = [
(format!("{} pcb export gerbers --output {} --layers {} {}", q(&cli), q(&gdir), layers.join(","), q(&wb)), "gerbers"),
(format!("{} pcb export drill --output {} --format excellon --drill-origin absolute --excellon-zeros-format decimal --excellon-units mm {}", q(&cli), q(&gdir), q(&wb)), "drill"),
(format!("{} pcb export pos --output {} --format csv --units mm --side both {}", q(&cli), q(&format!("{dest}/pos.csv")), q(&wb)), "placement"),
(format!("powershell -NoProfile -Command \"Compress-Archive -Path '{}\\*' -DestinationPath '{}' -Force\"", gdir.replace('/', "\\"), format!("{dest}/gerbers.zip").replace('/', "\\")), "zip"),
];
let mut log = Vec::new();
for (c, what) in &cmds {
let r = sh(c, &format!("JLCPCB export: {what}"));
let o = out_text(&r);
log.push(json!({"step": what, "exit": Bridge::field(&r, "exitCode"), "out": o.chars().rev().take(300).collect::<String>().chars().rev().collect::<String>()}));
}
let tmp = std::env::temp_dir().join(format!("aiflow-jlcpcb-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?;
let (gz_ok, gz) = br.pull(&format!("{dest}/gerbers.zip"), &tmp, 3);
let (pos_ok, pos) = br.pull(&format!("{dest}/pos.csv"), &tmp, 3);
sh(&format!("rmdir /s /q {}", q(&dest)), "clean up the JLCPCB export temp folder");
if !(gz_ok && pos_ok) { return Err(format!("files did not come back: {}", Value::from(log.clone()))); }
let names: Vec<String> = zip::ZipArchive::new(std::fs::File::open(&gz).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?.file_names().map(str::to_string).collect();
let mut missing: Vec<String> = ["F_Cu", "B_Cu", "F_Mask", "B_Mask", "F_Silkscreen", "Edge_Cuts", "F_Paste"].iter().filter(|n| !names.iter().any(|x| x.contains(*n))).map(|s| s.to_string()).collect();
if !names.iter().any(|x| x.to_lowercase().ends_with(".drl")) { missing.push("drill".into()); }
if !missing.is_empty() { return Err(format!("the gerber zip lacks {}", missing.join(", "))); }
let out_g = out.join(format!("{base}-gerbers.zip"));
std::fs::copy(&gz, &out_g).map_err(|e| e.to_string())?;
// BOM and CPL from the BOM rows and the placement file
let bom = Table::read(bom_path).map_err(|e| format!("{}: {e}", bom_path.display()))?;
let split = Regex::new(r"[ ,;]+").unwrap();
let (mut fitpost, mut lcsc_of, mut comment_of, mut mpn_of) = (BTreeSet::new(), BTreeMap::new(), BTreeMap::new(), BTreeMap::new());
for row in &bom.rows {
let fit = bom.get(row, "fit").trim().to_lowercase();
for rf in split.split(bom.get(row, "ref")).filter(|s| !s.is_empty()) {
if crate::rotation::SKIP_FIT.contains(&fit.as_str()) { fitpost.insert(rf.to_string()); continue; }
let l = bom.get(row, "lcsc").trim();
lcsc_of.insert(rf.to_string(), if l.is_empty() { bom.get(row, "vendor_pn").trim().to_string() } else { l.to_string() });
let v = bom.get(row, "value");
comment_of.insert(rf.to_string(), if v.is_empty() { bom.get(row, "mpn").to_string() } else { v.to_string() });
mpn_of.insert(rf.to_string(), bom.get(row, "mpn").to_string());
}
}
let placed = Table::read(&pos).map_err(|e| e.to_string())?;
let (mut cpl, mut fp_of, mut applied, mut nolcsc) = (Vec::new(), BTreeMap::new(), Map::new(), Vec::new());
let num = |s: &str| s.trim().parse::<f64>().unwrap_or(0.0);
for p in &placed.rows {
let rf = placed.get(p, "Ref").to_string();
if fitpost.contains(&rf) { continue; }
if lcsc_of.get(&rf).map(|s| s.is_empty()).unwrap_or(true) { nolcsc.push(rf); continue; }
let pkg = placed.get(p, "Package").to_string();
let add = rotations.get(&rf).or_else(|| rotations.get(&pkg)).and_then(|v| v.as_f64()).unwrap_or(0.0);
if add != 0.0 { applied.insert(rf.clone(), json!(add)); }
cpl.push(vec![rf.clone(), format!("{:.4}mm", num(placed.get(p, "PosX"))), format!("{:.4}mm", num(placed.get(p, "PosY"))),
if placed.get(p, "Side").to_lowercase().starts_with("top") { "Top".into() } else { "Bottom".into() },
g((num(placed.get(p, "Rot")) + add).rem_euclid(360.0))]);
fp_of.insert(rf, pkg);
}
let _ = std::fs::remove_dir_all(&tmp);
if !nolcsc.is_empty() { nolcsc.sort_by_key(|r| ref_key(r)); return Err(format!("no LCSC number for {} (add it to the BOM, or fit=post for parts fitted after assembly)", nolcsc.join(", "))); }
let mut refs: Vec<&String> = fp_of.keys().collect();
refs.sort_by_key(|r| ref_key(r));
let mut groups: Vec<(String, Vec<String>)> = Vec::new();
for rf in refs {
let l = &lcsc_of[rf];
match groups.iter_mut().find(|(k, _)| k == l) { Some(gr) => gr.1.push(rf.clone()), None => groups.push((l.clone(), vec![rf.clone()])) }
}
let bom_out = out.join(format!("{base}-bom-jlcpcb.csv"));
let cpl_out = out.join(format!("{base}-cpl-jlcpcb.csv"));
let mut b = vec![vec!["Comment".to_string(), "Designator".into(), "Footprint".into(), "LCSC Part #".into()]];
for (l, rs) in &groups { b.push(vec![format!("{} ({})", comment_of[&rs[0]], mpn_of[&rs[0]]), rs.join(","), fp_of[&rs[0]].clone(), l.clone()]); }
std::fs::write(&bom_out, csvio::write(&b, "\r\n")).map_err(|e| e.to_string())?;
let mut c = vec![vec!["Designator".to_string(), "Mid X".into(), "Mid Y".into(), "Layer".into(), "Rotation".into()]];
c.extend(cpl.iter().cloned());
std::fs::write(&cpl_out, csvio::write(&c, "\r\n")).map_err(|e| e.to_string())?;
let sha = crate::sha256_file;
let man = json!({"profile": "jlcpcb", "board": board.display().to_string(), "boardSha256": sha(&board), "bom": bom_path.canonicalize().unwrap_or(bom_path.to_path_buf()).display().to_string(),
"made": crate::ledger::now(), "kicadCli": cli, "target": br.target, "gerbers": {"file": out_g.display().to_string(), "sha256": sha(&out_g), "files": names},
"bomJlcpcb": {"file": bom_out.display().to_string(), "sha256": sha(&bom_out), "lines": groups.len()},
"cplJlcpcb": {"file": cpl_out.display().to_string(), "sha256": sha(&cpl_out), "parts": cpl.len()},
"leftOut": {"fitPost": fitpost.iter().collect::<Vec<_>>()}, "rotationsApplied": applied, "log": log});
let man_path = out.join(format!("{base}-fab-manifest.json"));
std::fs::write(&man_path, serde_json::to_string_pretty(&man).unwrap() + "\n").map_err(|e| e.to_string())?;
let mut fp: Vec<&String> = fitpost.iter().collect();
fp.sort_by_key(|r| ref_key(r));
let summary = format!("{base}-gerbers.zip ({} gerber/drill files), {base}-bom-jlcpcb.csv ({} lines), {base}-cpl-jlcpcb.csv ({} parts); left out (fitted after assembly): {}",
man["gerbers"]["files"].as_array().map(|a| a.len()).unwrap_or(0), groups.len(), cpl.len(), if fp.is_empty() { "none".into() } else { fp.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" ") });
Ok(Package { manifest: man_path, cpl: cpl_out, base, summary })
}
/// The copper layer count of a board, from its layer table.
pub fn copper_layers(text: &str) -> usize {
let re = Regex::new(r#"\(\d+ "((?:F|B|In\d+)\.Cu)""#).unwrap();
re.captures_iter(text).map(|c| c[1].to_string()).collect::<BTreeSet<_>>().len()
}
/// DRC of the board under `dru` (JLCPCB's rules from adom/pcb-design-rules) on a temporary copy on the desktop, with
/// its native kicad-cli; the design's own rules file is never touched. Returns KiCad's JSON report.
pub fn drc_with_rules(br: &Bridge, board: &Path, dru: &str) -> Result<Value, String> {
let board = board.canonicalize().map_err(|e| format!("{}: {e}", board.display()))?;
let base = board.file_stem().and_then(|s| s.to_str()).unwrap_or("board").to_string();
let st = br.call("kicad_status", &json!({"reason": "find the native kicad-cli for the JLCPCB rules check"}));
let cli = Bridge::field(&st, "install").and_then(|i| i["kicadCli"].as_str()).map(str::to_string)
.or_else(|| std::env::var("KICAD_REMOTE_CLI").ok()).ok_or(format!("no KiCad on {} (set KICAD_REMOTE_CLI)", br.target))?;
let sh = |cmd: &str, why: &str| br.call("shell_execute", &json!({"command": cmd, "timeoutSeconds": 300, "reason": why}));
let tmpw = out_text(&sh("echo %TEMP%", "temp folder for the JLCPCB rules check")).trim().replace('\\', "/");
if tmpw.is_empty() { return Err(format!("the desktop {} did not answer (is it connected?)", br.target)); }
let dest = format!("{tmpw}/aiflow-jlcpcb-drc-{}", crate::ledger::now().replace([':', '-'], ""));
let local = std::env::temp_dir().join(format!("aiflow-jlcpcb-drc-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&local);
std::fs::create_dir_all(&local).map_err(|e| e.to_string())?;
std::fs::copy(&board, local.join(format!("{base}.kicad_pcb"))).map_err(|e| e.to_string())?;
let pro = board.with_extension("kicad_pro");
if pro.is_file() { std::fs::copy(&pro, local.join(format!("{base}.kicad_pro"))).map_err(|e| e.to_string())?; }
std::fs::write(local.join(format!("{base}.kicad_dru")), dru).map_err(|e| e.to_string())?;
let send: Vec<String> = std::fs::read_dir(&local).map_err(|e| e.to_string())?.flatten().map(|e| e.path().display().to_string()).collect();
br.call("send_files", &json!({"filePaths": send, "dest": dest, "reason": "JLCPCB rules check (a copy of the board with JLCPCB's rules)"}));
let q = |p: &str| format!("\"{}\"", p.replace('/', "\\"));
let report = format!("{dest}/drc.json");
let r = sh(&format!("{} pcb drc --format json --units mm --severity-all --output {} {}", q(&cli), q(&report), q(&format!("{dest}/{base}.kicad_pcb"))), "JLCPCB rules check: DRC");
let (ok, got) = br.pull(&report, &local, 3);
sh(&format!("rmdir /s /q {}", q(&dest)), "clean up the JLCPCB rules check temp folder");
if !ok { let _ = std::fs::remove_dir_all(&local); return Err(format!("no DRC report came back: {}", out_text(&r).chars().take(400).collect::<String>())); }
let v: Value = serde_json::from_str(&std::fs::read_to_string(&got).unwrap_or_default()).map_err(|e| format!("DRC report: {e}"))?;
let _ = std::fs::remove_dir_all(&local);
Ok(v)
}