main
John Lauer Publish 0.1.0 55a9481 2d ago
//! 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)
}