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.54: 1.0.54. Wiki #119: back-side footprints written by other tools are read the way KiCad reads them. A footprint o…
4f34c0c
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
//! Design rules by fab (wiki adom/kicad-bridge#73), ported from `handlers/design_rules.py`.
//!
//! The canonical per-vendor rules live on the wiki page adom/pcb-design-rules, which
//! renders one KiCad custom-rules file per (vendor, process, layer count) plus a
//! machine-readable index at `.../render/rules/index.json`. A rule change is a wiki
//! publish, not a bridge release: this module never carries rule values. It resolves
//! (fab, layers) to the page's file, stages it next to the board as
//! `<board stem>.kicad_dru` (the name KiCad loads automatically) and optionally
//! runs DRC so the caller sees the fab's limits applied.
//!
//! The 3rd party fab is the baseline and is listed first.
use std::path::{Path, PathBuf};
use std::time::Duration;
use serde_json::{json, Value};
pub const PAGE: &str = "https://wiki.adom.inc/adom/pcb-design-rules/render/";
pub const INDEX_URL: &str = "https://wiki.adom.inc/adom/pcb-design-rules/render/rules/index.json";
pub const HOUSE_SLUG: &str = "adom-instapcb";
const TIMEOUT: Duration = Duration::from_secs(20);
const USER_AGENT: &str = "adom-bridge-kicad design_rules";
/// GET a URL from the wiki (rustls with bundled roots: no OS CA store involved).
#[cfg(not(feature = "net"))]
pub fn fetch(_url: &str) -> Result<Vec<u8>, String> {
Err("the net feature is off in this build: no HTTPS client".into())
}
#[cfg(feature = "net")]
pub fn fetch(url: &str) -> Result<Vec<u8>, String> {
let config = ureq::Agent::config_builder().timeout_global(Some(TIMEOUT)).http_status_as_error(true).build();
let agent = ureq::Agent::new_with_config(config);
let mut resp = agent.get(url).header("User-Agent", USER_AGENT).call().map_err(|e| e.to_string())?;
resp.body_mut().with_config().limit(50 * 1024 * 1024).read_to_vec().map_err(|e| e.to_string())
}
/// The page's vendors, baseline fab first, then the page's own order.
pub fn index() -> Result<Vec<Value>, String> {
let bytes = fetch(INDEX_URL)?;
let data: Value = serde_json::from_slice(&bytes).map_err(|e| format!("index is not JSON: {e}"))?;
let mut vendors: Vec<Value> = data.get("vendors").and_then(Value::as_array).cloned().unwrap_or_default();
vendors.sort_by_key(|v| if v.get("vendorSlug").and_then(Value::as_str) == Some(HOUSE_SLUG) { 0 } else { 1 });
Ok(vendors)
}
/// Lowercase alphanumerics only, so "JLC PCB" and "jlcpcb" compare equal.
pub fn norm_key(s: &str) -> String {
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
}
fn s(v: &Value, key: &str) -> String {
v.get(key).and_then(Value::as_str).unwrap_or("").to_string()
}
/// Accept the slug, the display name, or a distinctive substring of either.
pub fn match_fab<'a>(vendors: &'a [Value], fab: &str) -> Vec<&'a Value> {
let want = norm_key(fab);
if want.is_empty() {
return Vec::new();
}
let exact: Vec<&Value> = vendors.iter().filter(|v| norm_key(&s(v, "vendorSlug")) == want || norm_key(&s(v, "vendor")) == want).collect();
if !exact.is_empty() {
return exact;
}
vendors.iter().filter(|v| norm_key(&s(v, "vendorSlug")).contains(&want) || norm_key(&s(v, "vendor")).contains(&want)).collect()
}
/// One `rules` row of `kicad_list_design_rules`.
pub fn rule_row(v: &Value) -> Value {
let files = v.get("files").cloned().unwrap_or(Value::Null);
let file = |k: &str| files.get(k).and_then(Value::as_str).filter(|x| !x.is_empty()).map(|x| format!("{PAGE}{x}"));
json!({
"fab": v.get("vendor"),
"slug": v.get("vendorSlug"),
"process": v.get("process"),
"layers": v.get("layerCount"),
"kicadDruUrl": file("kicadDru"),
"boardSetupUrl": file("kicadBoardSetup"),
"house": v.get("vendorSlug").and_then(Value::as_str) == Some(HOUSE_SLUG),
})
}
/// Python `handle_list_design_rules`.
pub fn list_design_rules(fab: Option<&str>) -> Value {
let vendors = match index() {
Ok(v) => v,
Err(e) => return json!({"success": false, "error": format!("Could not read {INDEX_URL}: {e}"),
"_hint": "The rules page is on wiki.adom.inc; check the desktop's network, then retry. Nothing is cached locally on purpose so a rule change is a wiki publish."}),
};
let picked: Vec<&Value> = match fab {
Some(f) if !f.is_empty() => match_fab(&vendors, f),
_ => vendors.iter().collect(),
};
let rows: Vec<Value> = picked.iter().map(|v| rule_row(v)).collect();
json!({"success": true, "count": rows.len(), "rules": rows, "indexUrl": INDEX_URL,
"_hint": "Pick a row and call kicad_set_design_rules {\"fab\": <slug>, \"layers\": <n>, \"boardPath\": <.kicad_pcb>}. Omit fab for the baseline fab (the 3rd party fab). Values come from the wiki page at call time, so do not copy them into code."})
}
/// What `set_design_rules` staged, before any DRC.
pub struct Staged {
pub dru_path: PathBuf,
pub backup: Option<PathBuf>,
pub source: Value,
pub rule_names: Vec<String>,
}
/// Resolve the rules file (local `druPath` or the wiki), validate it, back up and
/// replace `<board stem>.kicad_dru`. Returns the failure response on `Err`.
pub fn stage_rules(board_path: &Path, args: &Value) -> Result<Staged, Value> {
let dru_target = board_path.with_extension("kicad_dru");
let override_path = args.get("druPath").and_then(Value::as_str).map(str::trim).unwrap_or("");
let (body, source): (Vec<u8>, Value) = if !override_path.is_empty() {
let src = Path::new(override_path);
if !src.exists() {
return Err(json!({"success": false, "error": format!("druPath not found: {override_path}")}));
}
let body = std::fs::read(src).map_err(|e| json!({"success": false, "error": format!("cannot read druPath: {e}")}))?;
(body, json!({"kind": "local", "path": crate::libraries::norm_path(src)}))
} else {
let vendors = index().map_err(|e| json!({"success": false, "error": format!("Could not read {INDEX_URL}: {e}"),
"_hint": "Pass druPath with a local .kicad_dru to work offline, or retry when wiki.adom.inc is reachable."}))?;
let fab = args.get("fab").and_then(Value::as_str).filter(|f| !f.is_empty()).unwrap_or(HOUSE_SLUG);
let cands = match_fab(&vendors, fab);
if cands.is_empty() {
let mut known: Vec<String> = vendors.iter().map(|v| s(v, "vendorSlug")).collect();
known.sort();
known.dedup();
return Err(json!({"success": false, "error": format!("No fab matches '{fab}'"), "errorCode": "fab_not_found",
"knownFabs": known,
"_hint": "Use one of knownFabs (kicad_list_design_rules shows names, processes and layer counts)."}));
}
let layers: i64 = match args.get("layers") {
None | Some(Value::Null) => board_copper_layers(board_path).unwrap_or(2) as i64,
Some(Value::Number(n)) => match n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)) {
Some(i) => i,
None => return Err(json!({"success": false, "error": format!("layers must be an integer, got {n}")})),
},
Some(Value::String(st)) => match st.trim().parse::<i64>() {
Ok(i) => i,
Err(_) => return Err(json!({"success": false, "error": format!("layers must be an integer, got '{st}'")})),
},
Some(other) => return Err(json!({"success": false, "error": format!("layers must be an integer, got {other}")})),
};
let process = args.get("process").and_then(Value::as_str).filter(|p| !p.is_empty());
let pick: Vec<&Value> = cands
.iter()
.copied()
.filter(|v| {
let lc = v.get("layerCount").and_then(Value::as_i64).unwrap_or(0);
lc == layers && process.map(|p| norm_key(&s(v, "process")) == norm_key(p)).unwrap_or(true)
})
.collect();
let Some(v) = pick.first() else {
let proc_txt = process.map(|p| format!(" {p}")).unwrap_or_default();
return Err(json!({"success": false, "errorCode": "no_rules_for_layers",
"error": format!("{} has no {layers}-layer{proc_txt} rules on the page", s(cands[0], "vendor")),
"available": cands.iter().map(|c| json!({"process": c.get("process"), "layers": c.get("layerCount")})).collect::<Vec<_>>(),
"_hint": "Pick one of `available`, or pass druPath with your own file."}));
};
let rel = v.get("files").and_then(|f| f.get("kicadDru")).and_then(Value::as_str).unwrap_or("");
if rel.is_empty() {
return Err(json!({"success": false, "error": format!("The page lists no KiCad file for {} {layers}L", s(v, "vendor"))}));
}
let url = format!("{PAGE}{rel}");
let body = fetch(&url).map_err(|e| json!({"success": false, "error": format!("Could not download {url}: {e}")}))?;
(body, json!({"kind": "wiki", "url": url, "fab": v.get("vendor"), "slug": v.get("vendorSlug"),
"process": v.get("process"), "layers": layers}))
};
let text = String::from_utf8_lossy(&body).to_string();
let head: String = text.chars().take(200).collect();
if !head.contains("(version") || !text.contains("(rule") {
return Err(json!({"success": false, "errorCode": "not_a_kicad_dru",
"error": "The rules file does not look like a KiCad custom-rules file ('(version N)' header and '(rule ...)' blocks)",
"source": source, "head": text.chars().take(160).collect::<String>()}));
}
let mut backup = None;
if dru_target.exists() {
if args.get("keepExisting").map(truthy).unwrap_or(false) {
return Err(json!({"success": false, "errorCode": "dru_exists",
"error": format!("{} already exists and keepExisting is set", file_name(&dru_target)),
"existing": crate::libraries::norm_path(&dru_target), "source": source,
"_hint": "Drop keepExisting to replace it (the old file is backed up next to it)."}));
}
let bak = dru_target.with_file_name(format!("{}.bak-{}", file_name(&dru_target), timestamp()));
std::fs::copy(&dru_target, &bak).map_err(|e| json!({"success": false, "error": format!("cannot back up {}: {e}", dru_target.display())}))?;
backup = Some(bak);
}
std::fs::write(&dru_target, &body).map_err(|e| json!({"success": false, "error": format!("cannot write {}: {e}", dru_target.display())}))?;
Ok(Staged { dru_path: dru_target, backup, source, rule_names: rule_names(&text) })
}
/// Names of the `(rule "NAME" ...)` blocks, in file order.
pub fn rule_names(text: &str) -> Vec<String> {
text.lines()
.filter_map(|ln| {
let t = ln.trim();
if t.starts_with("(rule ") && t.contains('"') {
t.split('"').nth(1).map(str::to_string)
} else {
None
}
})
.collect()
}
fn file_name(p: &Path) -> String {
p.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
}
fn truthy(v: &Value) -> bool {
match v {
Value::Bool(b) => *b,
Value::Null => false,
Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
Value::String(s) => !s.is_empty(),
Value::Array(a) => !a.is_empty(),
Value::Object(o) => !o.is_empty(),
}
}
/// Count copper layers from the board's `(layers ...)` block, cheaply (first 20000
/// chars). `(0 "F.Cu" signal)` rows with a signal/power/mixed/jumper kind count.
pub fn board_copper_layers(board_path: &Path) -> Option<u32> {
let bytes = std::fs::read(board_path).ok()?;
let text = String::from_utf8_lossy(&bytes);
let head: String = text.chars().take(20000).collect();
copper_layers_in_text(&head)
}
pub fn copper_layers_in_text(head: &str) -> Option<u32> {
let i = head.find("(layers")?;
let mut depth = 0i32;
let mut end = head.len();
for (j, ch) in head[i..].char_indices() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
end = i + j + 1;
break;
}
}
_ => {}
}
}
let block = &head[i..end];
let parsed = crate::libraries::parse_sexp(block).ok()?;
let mut n = 0u32;
for row in parsed.as_list()?.iter().skip(1) {
let Some(l) = row.as_list() else { continue };
let num_ok = l.first().and_then(|a| a.as_atom()).map(|a| !a.is_empty() && a.bytes().all(|b| b.is_ascii_digit())).unwrap_or(false);
let cu = l.get(1).and_then(|a| a.as_atom()).map(|a| a.ends_with(".Cu")).unwrap_or(false);
let kind = l.get(2).and_then(|a| a.as_atom()).map(|k| matches!(k, "signal" | "power" | "mixed" | "jumper")).unwrap_or(false);
if num_ok && cu && kind {
n += 1;
}
}
if n == 0 { None } else { Some(n) }
}
/// `YYYYmmdd-HHMMSS` in UTC (the Python used local time; the port has no timezone
/// database and the suffix only needs to be unique and sortable).
pub fn timestamp() -> String {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;
let days = secs.div_euclid(86400);
let rem = secs.rem_euclid(86400);
let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60);
// Civil-from-days (Howard Hinnant).
let z = days + 719468;
let era = z.div_euclid(146097);
let doe = z.rem_euclid(146097);
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if mo <= 2 { y + 1 } else { y };
format!("{y:04}{mo:02}{d:02}-{h:02}{m:02}{s:02}")
}
/// The DRC the Python runs after staging (`handle_lint_board`): `pcb drc --format json
/// --severity-all --all-track-errors [--schematic-parity]`, shaped as
/// `{success, output, data:{violations, summary}}` so the caller's post-processing
/// matches. `cli` is kicad-cli; None means KiCad is not installed.
pub fn drc_with_rules(cli: Option<&Path>, board_path: &Path, schematic_parity: bool) -> Value {
let Some(cli) = cli else {
return json!({"success": false, "error": "KiCad not installed",
"_hint": "Ask the user to install KiCad from https://www.kicad.org/download/."});
};
let report = std::env::temp_dir().join(format!("adom-kicad-drc-rules-{}.json", std::process::id()));
let report_s = crate::libraries::norm_path(&report);
let board_s = crate::libraries::norm_path(board_path);
let mut argv: Vec<&str> = vec!["pcb", "drc", "--format", "json", "--severity-all", "--all-track-errors"];
if schematic_parity {
argv.push("--schematic-parity");
}
argv.extend(["--output", report_s.as_str(), board_s.as_str()]);
let r = crate::cli::run(cli, &argv, Duration::from_secs(280));
let parsed = std::fs::read_to_string(&report).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
let _ = std::fs::remove_file(&report);
match parsed {
Some(rep) => {
let violations = crate::cli::flatten_violations(&rep);
let errors = violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("error")).count();
let warnings = violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("warning")).count();
let info = violations.iter().filter(|v| v.get("severity").and_then(Value::as_str) == Some("info")).count();
let total = violations.len();
json!({
"success": true,
"output": format!("Lint complete: {errors} error(s), {warnings} warning(s), {total} total"),
"data": {
"violations": violations,
"summary": {"total": total, "errors": errors, "warnings": warnings, "info": info},
"schematicParityChecked": schematic_parity,
"source_file": board_s,
},
"cliExitCode": r.exit_code,
"cliElapsedMs": r.elapsed_ms,
})
}
None => json!({
"success": false,
"error": if r.timed_out { "kicad-cli timed out".to_string() } else { format!("kicad-cli failed: {}", r.stderr.trim()) },
"stdout": r.stdout,
"stderr": r.stderr,
"_hint": "If stderr mentions 'older format', the file needs upgrading (kicad-cli pcb upgrade, or open and save in KiCad). If schematic-parity failed because no matching .kicad_sch was found, retry with schematicParity:false.",
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vendors() -> Vec<Value> {
vec![
json!({"vendor": "JLCPCB", "vendorSlug": "jlcpcb", "process": "standard", "layerCount": 2, "files": {"kicadDru": "kicad/jlcpcb-2L.kicad_dru", "kicadBoardSetup": "kicad/jlcpcb-2L.json"}}),
json!({"vendor": "3rd party fab", "vendorSlug": HOUSE_SLUG, "process": "standard", "layerCount": 2, "files": {"kicadDru": format!("kicad/{HOUSE_SLUG}-2L.kicad_dru")}}),
json!({"vendor": "JLCPCB", "vendorSlug": "jlcpcb", "process": "advanced", "layerCount": 4, "files": {}}),
]
}
#[test]
fn fab_matching() {
let v = vendors();
assert_eq!(match_fab(&v, "JLC PCB").len(), 2);
assert_eq!(match_fab(&v, "jlcpcb").len(), 2);
assert_eq!(match_fab(&v, "3rd party").len(), 1);
assert_eq!(match_fab(&v, "3rd party fab")[0]["vendorSlug"], json!(HOUSE_SLUG));
assert!(match_fab(&v, "").is_empty());
assert!(match_fab(&v, "oshpark").is_empty());
}
#[test]
fn rows_carry_urls_and_house_flag() {
let v = vendors();
let r = rule_row(&v[0]);
assert_eq!(r["kicadDruUrl"], json!("https://wiki.adom.inc/adom/pcb-design-rules/render/kicad/jlcpcb-2L.kicad_dru"));
assert_eq!(r["boardSetupUrl"], json!("https://wiki.adom.inc/adom/pcb-design-rules/render/kicad/jlcpcb-2L.json"));
assert_eq!(r["house"], json!(false));
let r = rule_row(&v[1]);
assert_eq!(r["boardSetupUrl"], Value::Null);
assert_eq!(r["house"], json!(true));
assert_eq!(rule_row(&v[2])["kicadDruUrl"], Value::Null);
}
#[test]
fn copper_layer_count() {
let board = r#"(kicad_pcb (version 20240108) (generator "pcbnew")
(general (thickness 1.6))
(layers
(0 "F.Cu" signal)
(1 "In1.Cu" power)
(2 "In2.Cu" mixed)
(31 "B.Cu" signal)
(32 "B.Adhes" user "B.Adhesive")
(36 "B.SilkS" user "B.Silkscreen")
)
(setup (pad_to_mask_clearance 0)))"#;
assert_eq!(copper_layers_in_text(board), Some(4));
assert_eq!(copper_layers_in_text("(kicad_pcb (general))"), None);
assert_eq!(copper_layers_in_text("(kicad_pcb (layers (32 \"B.Adhes\" user)))"), None);
}
#[test]
fn rule_names_and_dru_sniff() {
let dru = "(version 1)\n(rule \"min-track-width\"\n (constraint track_width (min 0.1mm)))\n(rule \"via-drill\" (constraint hole_size (min 0.2mm)))\n";
assert_eq!(rule_names(dru), vec!["min-track-width".to_string(), "via-drill".to_string()]);
}
#[test]
fn stage_rules_from_local_file_with_backup() {
let dir = std::env::temp_dir().join(format!("adom-dru-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let board = dir.join("board.kicad_pcb");
std::fs::write(&board, "(kicad_pcb (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)))").unwrap();
let src = dir.join("rules.kicad_dru");
std::fs::write(&src, "(version 1)\n(rule \"a\" (constraint track_width (min 0.1mm)))\n").unwrap();
let existing = dir.join("board.kicad_dru");
std::fs::write(&existing, "(version 1)\n(rule \"old\")\n").unwrap();
let args = json!({"druPath": src.to_string_lossy(), "keepExisting": true});
let e = stage_rules(&board, &args).err().unwrap();
assert_eq!(e["errorCode"], json!("dru_exists"));
let args = json!({"druPath": src.to_string_lossy()});
let st = stage_rules(&board, &args).ok().unwrap();
assert_eq!(st.dru_path, existing);
assert!(st.backup.as_ref().unwrap().file_name().unwrap().to_string_lossy().starts_with("board.kicad_dru.bak-"));
assert_eq!(st.rule_names, vec!["a".to_string()]);
assert_eq!(st.source["kind"], json!("local"));
assert_eq!(std::fs::read_to_string(&existing).unwrap(), std::fs::read_to_string(&src).unwrap());
// Not a rules file.
let bad = dir.join("bad.kicad_dru");
std::fs::write(&bad, "hello").unwrap();
let e = stage_rules(&board, &json!({"druPath": bad.to_string_lossy()})).err().unwrap();
assert_eq!(e["errorCode"], json!("not_a_kicad_dru"));
let e = stage_rules(&board, &json!({"druPath": dir.join("missing").to_string_lossy()})).err().unwrap();
assert!(e["error"].as_str().unwrap().starts_with("druPath not found"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn timestamp_shape() {
let t = timestamp();
assert_eq!(t.len(), 15);
assert_eq!(&t[8..9], "-");
assert!(t[..8].bytes().all(|b| b.is_ascii_digit()));
assert!(t.starts_with("20"));
}
}