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
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291
//! The JLCPCB rotation check: prove every placed part lands pin 1 on pad 1 before the order goes in.
//!
//! JLCPCB places each part from the footprint attached to its LCSC number (EasyEDA's library), at the rotation and
//! centroid in our CPL. KiCad's footprint for the same package often has a different zero rotation (SOT-23, TSOT,
//! SOIC, QFN, electrolytics), so the CPL needs a per-part correction. This finds it by geometry, not by a table:
//!
//! E = JLCPCB's pads (EasyEDA footprint for the C-number, pad numbers, mm, footprint frame)
//! K = the board footprint's pads (same numbers, footprint frame)
//! the rotation d in {0, 90, 180, 270} and offset t with R(d)E + t == K pad for pad (max error <= tol)
//! CPL rotation = KiCad rotation + d; CPL centroid shifted by t turned into the board frame
//!
//! JLCPCB's footprint API answers browsers only, so it is read through a pup window on the target desktop.
use crate::bridge::Bridge;
use crate::csvio::{self, Table};
use crate::kicad::{self, g, ref_key};
use regex::Regex;
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
use std::path::Path;
const MIL: f64 = 0.254; // EasyEDA units are 10 mil
const WINDOW: &str = "aiflow-jlcpcb";
pub const SKIP_FIT: [&str; 5] = ["post", "dnp", "hand", "after", "no"];
pub const PAGE_FILE: &str = "jlcpcb-rotation.json";
type Pads = BTreeMap<String, (f64, f64)>;
pub struct JlcFootprint { pub title: String, pub pads: Pads }
/// JLCPCB's footprint for one LCSC number, or None when EasyEDA has none.
pub fn jlc_footprint(br: &Bridge, lcsc: &str) -> Option<JlcFootprint> {
let url = format!("https://easyeda.com/api/products/{lcsc}/components?version=6.4.19.5");
let w = br.call("pup_list_windows", &json!({"reason": "find the JLCPCB footprint window"}));
let have = Bridge::field(&w, "sessions").and_then(|s| s.as_array()).map(|a| a.iter().any(|s| s["window"] == WINDOW)).unwrap_or(false);
if have { br.call("pup_navigate", &json!({"window": WINDOW, "url": url, "reason": format!("JLCPCB footprint for {lcsc}")})); }
else { br.call("pup_open_window", &json!({"window": WINDOW, "url": url, "reason": "read JLCPCB footprints for the rotation check"})); }
// the page must be THIS part's (never the previous part's answer); a missing part answers at once
let expr = format!("(async()=>{{for(let i=0;i<40;i++){{try{{if(!location.href.includes('/{lcsc}/'))throw 0;const d=JSON.parse(document.body.innerText);const p=(d.result||{{}}).packageDetail||{{}};\
const ds=p.dataStr||{{}};if(ds.head)return JSON.stringify({{title:p.title,head:ds.head,pads:(ds.shape||[]).filter(s=>s.startsWith('PAD'))}});\
if(d.success===false||d.code===404||!d.result)return JSON.stringify({{notFound:String(d.message||d.code||'no result')}})}}catch(e){{}}\
await new Promise(r=>setTimeout(r,500))}}return JSON.stringify({{error:location.href+' '+document.body.innerText.slice(0,200)}})}})()");
let mut d = Value::Null;
for _ in 0..3 {
let r = br.pup_eval(WINDOW, &expr, &format!("parse the JLCPCB footprint for {lcsc}"));
d = match Bridge::field(&r, "result") { Some(Value::String(s)) => serde_json::from_str(s).unwrap_or(Value::Null), Some(v) => v.clone(), None => Value::Null };
if d.get("head").is_some() || d.get("notFound").is_some() { break; }
br.call("pup_navigate", &json!({"window": WINDOW, "url": url, "reason": format!("retry JLCPCB footprint for {lcsc}")}));
}
let head = d.get("head")?;
let num = |v: &Value| v.as_str().and_then(|s| s.parse::<f64>().ok()).or_else(|| v.as_f64()).unwrap_or(0.0);
let (hx, hy) = (num(&head["x"]), num(&head["y"]));
let mut pads = Pads::new();
for s in d["pads"].as_array().into_iter().flatten().filter_map(|s| s.as_str()) {
let f: Vec<&str> = s.split('~').collect(); // PAD~shape~x~y~w~h~layer~net~number~...
if f.len() < 9 { continue; }
if let (Ok(x), Ok(y)) = (f[2].parse::<f64>(), f[3].parse::<f64>()) {
pads.entry(f[8].to_string()).or_insert(((x - hx) * MIL, (y - hy) * MIL));
}
}
Some(JlcFootprint { title: d["title"].as_str().unwrap_or("").to_string(), pads })
}
/// Rotate CCW-positive (as seen y-up) in y-down coordinates.
pub fn rot(p: (f64, f64), deg: f64) -> (f64, f64) {
let (s, c) = deg.to_radians().sin_cos();
(p.0 * c + p.1 * s, -p.0 * s + p.1 * c)
}
pub struct Fit { pub delta: i64, pub residual: f64, pub offset: (f64, f64), pub compared: usize }
/// The rotation of E that best lands its pads on K, pad number for pad number.
pub fn fit(e: &Pads, k: &Pads) -> Option<Fit> {
let common: Vec<&String> = k.keys().filter(|n| !n.is_empty() && e.contains_key(*n)).collect();
if common.len() < 2 { return None; }
let n = common.len() as f64;
let mut best: Option<Fit> = None;
for d in [0i64, 90, 180, 270] {
let rp: Vec<(f64, f64)> = common.iter().map(|c| rot(e[*c], d as f64)).collect();
let t = (common.iter().zip(&rp).map(|(c, r)| k[*c].0 - r.0).sum::<f64>() / n, common.iter().zip(&rp).map(|(c, r)| k[*c].1 - r.1).sum::<f64>() / n);
let err = common.iter().zip(&rp).map(|(c, r)| ((r.0 + t.0 - k[*c].0).powi(2) + (r.1 + t.1 - k[*c].1).powi(2)).sqrt()).fold(0.0, f64::max);
if best.as_ref().map(|b| err < b.residual).unwrap_or(true) { best = Some(Fit { delta: d, residual: err, offset: t, compared: common.len() }); }
}
best
}
fn r4(v: f64) -> f64 { (v * 10000.0).round() / 10000.0 }
pub fn signed(delta: i64) -> i64 { (delta + 180).rem_euclid(360) - 180 }
pub struct Check { pub record: Value, pub failed: Vec<(String, String)>, pub warned: Vec<(String, String)> }
/// Check every placed part with an LCSC number; `overrides` are hand-set corrections (ref or footprint -> degrees),
/// used only where JLCPCB publishes no footprint.
pub fn check(br: &Bridge, board: &Path, bom: &Table, overrides: &Map<String, Value>, tol: f64) -> Check {
let text = std::fs::read_to_string(board).unwrap_or_default();
let parts = kicad::footprints(&text);
let re_c = Regex::new(r"^C\d+$").unwrap();
let split = Regex::new(r"[ ,;]+").unwrap();
let (mut lcsc, mut mpn) = (BTreeMap::new(), BTreeMap::new());
for row in &bom.rows {
if SKIP_FIT.contains(&bom.get(row, "fit").trim().to_lowercase().as_str()) { continue; }
let c = { let l = bom.get(row, "lcsc").trim(); if l.is_empty() { bom.get(row, "vendor_pn").trim() } else { l } }.to_string();
if !re_c.is_match(&c) { continue; }
for rf in split.split(bom.get(row, "ref")).filter(|s| !s.is_empty()) { lcsc.insert(rf.to_string(), c.clone()); mpn.insert(rf.to_string(), bom.get(row, "mpn").to_string()); }
}
let mut order: Vec<String> = lcsc.keys().filter(|r| parts.contains_key(*r)).cloned().collect();
order.sort_by_key(|r| ref_key(r));
let mut cache: BTreeMap<String, Option<JlcFootprint>> = BTreeMap::new();
for rf in &order { let c = &lcsc[rf]; if !cache.contains_key(c) { cache.insert(c.clone(), jlc_footprint(br, c)); } }
let mut by_fp: BTreeMap<String, String> = BTreeMap::new(); // KiCad footprint -> an LCSC number with a JLCPCB footprint
for rf in &order { if cache[&lcsc[rf]].is_some() { by_fp.entry(parts[rf].name.clone()).or_insert(lcsc[rf].clone()); } }
let ov = |rf: &str, fp: &str| overrides.get(rf).or_else(|| overrides.get(fp)).and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)));
let (mut recs, mut failed, mut warned) = (Vec::new(), Vec::new(), Vec::new());
for rf in &order {
let (c, p) = (&lcsc[rf], &parts[rf]);
let mut src = c.clone();
let mut jf = cache[c].as_ref();
if jf.is_none() { if let Some(o) = by_fp.get(&p.name) { src = o.clone(); jf = cache[o].as_ref(); } }
let base = json!({"ref": rf, "mpn": mpn[rf], "lcsc": c, "kicadFootprint": p.name, "kicadRotation": p.rot});
let Some(jf) = jf else {
let mut rec = base;
match ov(rf, &p.name) {
Some(m) => {
rec["status"] = json!("manual"); rec["delta"] = json!(m.rem_euclid(360)); rec["jlcRotation"] = json!((p.rot + m as f64).rem_euclid(360.0));
warned.push((rf.clone(), format!("no JLCPCB footprint; hand-set correction {m:+} kept (confirm it in JLCPCB's placement preview)")));
}
None => {
rec["status"] = json!("no-jlc-footprint");
warned.push((rf.clone(), format!("JLCPCB publishes no footprint for {c} and no part on this board shares {}: confirm it in JLCPCB's placement preview", p.name)));
}
}
recs.push(rec); continue;
};
let k: Pads = p.pads.iter().map(|(n, q)| (n.clone(), (q.x, q.y))).collect();
let mut rec = base;
rec["jlcFootprint"] = json!(jf.title);
if src != *c { rec["jlcFootprintFrom"] = json!(src); }
let Some(f) = fit(&jf.pads, &k) else {
failed.push((rf.clone(), format!("no common pad numbers between {} and {}", p.name, jf.title)));
rec["status"] = json!("no-match"); recs.push(rec); continue;
};
rec["delta"] = json!(f.delta); rec["residual_mm"] = json!(r4(f.residual)); rec["offset_mm"] = json!([r4(f.offset.0), r4(f.offset.1)]); rec["pads_compared"] = json!(f.compared);
rec["jlcRotation"] = json!((p.rot + f.delta as f64).rem_euclid(360.0));
let (ox, oy) = rot((r4(f.offset.0), r4(f.offset.1)), p.rot); // into the board frame (y down)
rec["centroidShiftBoard_mm"] = json!([r4(-ox), r4(-oy)]);
let ok = r4(f.residual) <= tol;
rec["status"] = json!(if ok { "ok" } else { "mismatch" });
rec["pads"] = json!({"kicad": p.pads.iter().map(|(n, q)| (n.clone(), json!([r4(q.x), r4(q.y), r4(q.w), r4(q.h)]))).collect::<Map<_, _>>(),
"jlc": jf.pads.iter().map(|(n, q)| (n.clone(), json!([r4(q.0), r4(q.1)]))).collect::<Map<_, _>>()});
if let Some(m) = ov(rf, &p.name) {
if ok && m.rem_euclid(360) != f.delta { warned.push((rf.clone(), format!("hand-set correction {m:+} disagrees with the pad fit ({:+}); the pad fit wins", signed(f.delta)))); }
}
if !ok { failed.push((rf.clone(), format!("best rotation leaves {:.3} mm error (> {tol:.2}): wrong footprint or mirrored part", f.residual))); }
recs.push(rec);
}
let record = json!({"schema": "adom/jlcpcb-rotation-run@1", "board": board.canonicalize().unwrap_or(board.to_path_buf()).display().to_string(),
"boardSha256": crate::sha256_file(board), "checked": crate::ledger::now(), "method": "pad-by-pad fit of JLCPCB's (EasyEDA) footprint to the board footprint",
"tolerance_mm": tol, "parts": recs, "failed": failed.iter().map(|(r, w)| json!({"ref": r, "why": w})).collect::<Vec<_>>(),
"warnings": warned.iter().map(|(r, w)| json!({"ref": r, "why": w})).collect::<Vec<_>>()});
Check { record, failed, warned }
}
/// Write each part's corrected rotation (and centroid, when the origins differ) into the CPL.
pub fn apply(cpl_path: &Path, record: &Value) -> std::io::Result<()> {
let t = Table::read(cpl_path)?;
let by: BTreeMap<&str, &Value> = record["parts"].as_array().into_iter().flatten().filter_map(|r| r["ref"].as_str().map(|s| (s, r))).collect();
let (ci, cr, cx, cy) = (t.col("Designator"), t.col("Rotation"), t.col("Mid X"), t.col("Mid Y"));
let mut rows = vec![t.head.clone()];
for mut row in t.rows.clone() {
if let (Some(ci), Some(cr)) = (ci, cr) {
if let Some(r) = by.get(row[ci].as_str()).filter(|r| r.get("jlcRotation").is_some()) {
row[cr] = g(r["jlcRotation"].as_f64().unwrap_or(0.0));
let (sx, sy) = (r["centroidShiftBoard_mm"][0].as_f64().unwrap_or(0.0), r["centroidShiftBoard_mm"][1].as_f64().unwrap_or(0.0));
if sx.abs() > 0.005 || sy.abs() > 0.005 { // CPL Y is the board Y negated (KiCad exports y up)
let mm = |s: &str| s.trim_end_matches("mm").parse::<f64>().unwrap_or(0.0);
if let (Some(cx), Some(cy)) = (cx, cy) { row[cx] = format!("{:.4}mm", mm(&row[cx]) + sx); row[cy] = format!("{:.4}mm", mm(&row[cy]) - sy); }
}
}
}
rows.push(row);
}
std::fs::write(cpl_path, csvio::write(&rows, "\r\n"))
}
pub fn summary(c: &Check, applied: bool) -> String {
let parts = c.record["parts"].as_array().cloned().unwrap_or_default();
let turned: Vec<String> = parts.iter().filter(|r| r["delta"].as_i64().unwrap_or(0) != 0).map(|r| format!("{} {:+}", r["ref"].as_str().unwrap_or(""), signed(r["delta"].as_i64().unwrap_or(0)))).collect();
format!("rotation check: {} parts, {} need a JLCPCB rotation correction ({}), {} failed{}", parts.len(), turned.len(),
if turned.is_empty() { "none".into() } else { turned.join(", ") }, c.failed.len(), if applied { "; CPL updated" } else { "" })
}
fn wiki(args: &[&str], cwd: Option<&Path>) -> (bool, String) {
let mut c = std::process::Command::new("adom-wiki");
c.args(args);
if let Some(d) = cwd { c.current_dir(d); }
match c.output() { Ok(o) => (o.status.success(), String::from_utf8_lossy(&o.stdout).to_string() + &String::from_utf8_lossy(&o.stderr)), Err(e) => (false, e.to_string()) }
}
pub struct Evidence<'a> { pub screenshot: Option<&'a Path>, pub boards: Option<u64>, pub order: Option<&'a str> }
/// Merge one part's correction and evidence into jlcpcb-rotation.json (adom/jlcpcb-rotation@1) on its wiki page.
/// One entry per (LCSC number, KiCad footprint); evidence is deduplicated so re-publishing does not inflate counts.
pub fn publish(record: &Value, reference: &str, page: &str, ev: &Evidence, dry_run: bool) -> Result<String, String> {
let rec = record["parts"].as_array().into_iter().flatten().find(|r| r["ref"] == reference).ok_or(format!("{reference} is not in the rotation record"))?;
let status = rec["status"].as_str().unwrap_or("");
if status != "ok" && status != "manual" { return Err(format!("{reference} has status {status}; only a fitted (ok) or hand-set (manual) correction is published")); }
if status == "manual" && ev.screenshot.is_none() && ev.boards.is_none() { return Err(format!("{reference} is hand-set (no JLCPCB footprint to fit): publish it only with a JLCPCB preview screenshot or delivered boards")); }
let by = serde_json::from_str::<Value>(&wiki(&["whoami", "--json"], None).1).ok()
.and_then(|w| if w.get("data").is_some() { w["data"]["user"]["username"].as_str().map(str::to_string) } else { w["user"]["username"].as_str().map(str::to_string) }).unwrap_or("unknown".into());
let (got, cur) = wiki(&["repo", "show", page, PAGE_FILE], None);
let mut doc = if got { serde_json::from_str::<Value>(&cur).ok().filter(|d| d.get("entries").is_some()) } else { None }.unwrap_or_else(|| json!({
"schema": "adom/jlcpcb-rotation@1", "part": rec["mpn"], "entries": [],
"about": "Rotation to add to the KiCad footprint's rotation in a JLCPCB CPL so JLCPCB places this part pin 1 on pad 1. correction_deg is CCW-positive as seen from the top; centroidOffset_mm is in the KiCad footprint frame (mm, y down). Written by adom-aiflow-jlcpcb (pad-by-pad fit of JLCPCB's EasyEDA footprint to the KiCad footprint)."}));
let corr = signed(rec["delta"].as_i64().unwrap_or(0));
let entries = doc["entries"].as_array_mut().unwrap();
let idx = match entries.iter().position(|e| e["lcsc"] == rec["lcsc"] && e["kicadFootprint"] == rec["kicadFootprint"]) {
Some(i) => i,
None => { entries.push(json!({"lcsc": rec["lcsc"], "kicadFootprint": rec["kicadFootprint"], "evidence": []})); entries.len() - 1 }
};
let e = &mut entries[idx];
if let Some(old) = e["correction_deg"].as_i64() {
if old != corr { return Err(format!("the page says {old:+} for {} on {} but this run found {corr:+}; settle it in JLCPCB's preview before publishing", rec["lcsc"], rec["kicadFootprint"])); }
}
e["correction_deg"] = json!(corr);
e["method"] = json!(if status == "ok" { "pad-fit" } else { "hand-set" });
for k in ["jlcFootprint", "jlcFootprintFrom", "residual_mm", "pads_compared", "pads"] { if let Some(v) = rec.get(k) { e[k] = v.clone(); } }
if let Some(v) = rec.get("offset_mm") { e["centroidOffset_mm"] = v.clone(); }
let today = crate::ledger::now()[..10].to_string();
let day = record["checked"].as_str().map(|s| s[..10.min(s.len())].to_string()).unwrap_or(today.clone());
let board_sha = record["boardSha256"].clone();
let mut new = Vec::new();
if status == "ok" { new.push(json!({"kind": "pad-fit", "date": day, "by": by, "boardSha256": board_sha, "residual_mm": rec["residual_mm"]})); }
let mut shot_path = None;
if let Some(s) = ev.screenshot {
let ext = s.extension().and_then(|x| x.to_str()).unwrap_or("png");
let p = format!("docs/jlcpcb-rotation/{}-{}-preview.{ext}", rec["lcsc"].as_str().unwrap_or(""), &crate::sha256_file(s)[..8]);
let shot_day = std::process::Command::new("date").args(["-r", &s.display().to_string(), "+%F"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).filter(|d| !d.is_empty()).unwrap_or(today.clone());
new.push(json!({"kind": "jlc-preview", "date": shot_day, "by": by, "screenshot": p, "boardSha256": board_sha}));
shot_path = Some(p);
}
if let Some(n) = ev.boards { new.push(json!({"kind": "boards-delivered", "date": today, "by": by, "count": n, "order": ev.order, "boardSha256": board_sha})); }
let sig = |v: &Value| (v["kind"].to_string(), v["boardSha256"].to_string(), v["screenshot"].to_string(), v["order"].to_string(), v["by"].to_string());
let have: Vec<_> = e["evidence"].as_array().cloned().unwrap_or_default().iter().map(sig).collect();
let added: Vec<String> = new.iter().filter(|v| !have.contains(&sig(v))).map(|v| v["kind"].as_str().unwrap_or("").to_string()).collect();
for v in new.into_iter().filter(|v| !have.contains(&sig(v))) { e["evidence"].as_array_mut().unwrap().push(v); }
let evs = e["evidence"].as_array().cloned().unwrap_or_default();
let count = |k: &str| evs.iter().filter(|v| v["kind"] == k).count();
let users: std::collections::BTreeSet<String> = evs.iter().map(|v| v["by"].to_string()).collect();
e["verified"] = json!({"padFit": count("pad-fit"), "jlcPreview": count("jlc-preview"),
"boardsDelivered": evs.iter().filter(|v| v["kind"] == "boards-delivered").map(|v| v["count"].as_u64().unwrap_or(0)).sum::<u64>(), "users": users.len()});
let verified = e["verified"].clone();
doc["updated"] = json!(today);
let text = serde_json::to_string_pretty(&doc).unwrap() + "\n";
if dry_run { return Ok(text); }
let tmp = std::env::temp_dir().join(format!("jlcrot-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).map_err(|e| e.to_string())?;
std::fs::write(tmp.join(PAGE_FILE), &text).map_err(|e| e.to_string())?;
let mut files = vec![PAGE_FILE.to_string()];
if let (Some(p), Some(s)) = (&shot_path, ev.screenshot) {
let dest = tmp.join(p);
std::fs::create_dir_all(dest.parent().unwrap()).map_err(|e| e.to_string())?;
std::fs::copy(s, &dest).map_err(|e| e.to_string())?;
files.push(p.clone());
}
let msg = format!("jlcpcb-rotation: {} {corr:+} on {} ({})", rec["lcsc"].as_str().unwrap_or(""), rec["kicadFootprint"].as_str().unwrap_or(""), if added.is_empty() { "no new evidence".into() } else { added.join(", ") });
let mut args = vec!["repo", "push", page, "--files"];
args.extend(files.iter().map(|s| s.as_str()));
args.extend(["-m", msg.as_str()]);
let (pushed, out) = wiki(&args, Some(&tmp));
let _ = std::fs::remove_dir_all(&tmp);
if !pushed { return Err(format!("push failed: {}", out.chars().take(600).collect::<String>())); }
let (got, back) = wiki(&["repo", "show", page, PAGE_FILE], None);
let same = got && serde_json::from_str::<Value>(&back).ok() == Some(doc.clone());
Ok(format!("published {} {corr:+} to {page}/{PAGE_FILE} ({verified}); read back {}", rec["lcsc"].as_str().unwrap_or(""), if same { "ok" } else { "MISMATCH" }))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_a_quarter_turn() {
// a SOT-23-6 style footprint, turned 90 degrees and shifted on the board side
let e: Pads = [("1", (-0.95, 1.3)), ("2", (0.0, 1.3)), ("3", (0.95, 1.3)), ("4", (0.95, -1.3)), ("5", (0.0, -1.3)), ("6", (-0.95, -1.3))]
.iter().map(|(n, p)| (n.to_string(), *p)).collect();
let k: Pads = e.iter().map(|(n, p)| { let r = rot(*p, 270.0); (n.clone(), (r.0 + 0.1, r.1)) }).collect();
let f = fit(&e, &k).unwrap();
assert_eq!((f.delta, signed(f.delta)), (270, -90));
assert!(f.residual < 1e-9 && (f.offset.0 - 0.1).abs() < 1e-9);
}
}