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
Colby Knox
Merge PR #316: kicad-lane: 1.0.70, build_schematic, bundle planOnly (#106), engine 0.2.1
6b2471f
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
//! GenCAD 1.4 written from the board model (mm, y up), for a bridge whose tool has no GenCAD exporter (Altium has none).
//! Outline, one PAD and PADSTACK per pad shape and drill, one SHAPE per footprint with pins in its own frame (the part's
//! rotation removed, a bottom part mirrored back to the top view), COMPONENT (bottom parts MIRRORX FLIP), DEVICE,
//! SIGNAL/NODE, TRACK codes by width and ROUTE with LINE/ARC/VIA, in inches. The syntax follows a file KiCad 10 wrote.
//! No I/O: the bridge writes the text.
use serde_json::{json, Value};
use super::dxf::{bulge, f, is_copper};
/// GenCAD's name for a copper layer of the board model.
fn gencad_layer(l: &str) -> String {
match l {
"F.Cu" => "TOP".into(),
"B.Cu" => "BOTTOM".into(),
_ => l.strip_prefix("In").and_then(|n| n.strip_suffix(".Cu")).map(|n| format!("INNER{n}")).unwrap_or_else(|| "TOP".into()),
}
}
fn q(s: &str) -> String { format!("\"{}\"", s.replace('"', "'")) }
/// The board as GenCAD 1.4 text, in inches as fixture and assembly tools expect: outline, pads and padstacks, one
/// shape per footprint (pins in the footprint's own frame), components, devices, signals, and the routes with their
/// vias. Returns the text and counts.
pub fn gencad_text(state: &Value, title: &str, unique_pins: bool) -> (String, Value) {
let inch = |mm: f64| format!("{:.6}", mm / 25.4);
let arr = |k: &str| state[k].as_array().cloned().unwrap_or_default();
let mut o = String::new();
o += &format!("$HEADER\nGENCAD 1.4\nUSER {}\nDRAWING {}\nREVISION {}\nUNITS INCH\nORIGIN 0 0\nINTERTRACK 0\n$ENDHEADER\n",
q(&format!("Adom EDA engine {}", env!("CARGO_PKG_VERSION"))), q(title), q("1"));
// the outline
o += "$BOARD\n";
for e in arr("outlineEdges") {
let (s, t) = ((f(&e["start"][0]), f(&e["start"][1])), (f(&e["end"][0]), f(&e["end"][1])));
if e["kind"] == "arc" {
// GenCAD arcs run counter-clockwise from the first point to the second
let (a, b) = if bulge(&e) >= 0.0 { (s, t) } else { (t, s) };
o += &format!("ARC {} {} {} {} {} {}\n", inch(a.0), inch(a.1), inch(b.0), inch(b.1), inch(f(&e["center"][0])), inch(f(&e["center"][1])));
} else {
o += &format!("LINE {} {} {} {}\n", inch(s.0), inch(s.1), inch(t.0), inch(t.1));
}
}
o += "$ENDBOARD\n";
// pads: one per shape, size and drill, in the footprint's frame
let comps = arr("components");
let pads = arr("pads");
let mut pad_defs: Vec<(String, f64, f64, f64)> = Vec::new(); // (shape, w, h, drill)
let mut pad_name = |shape: &str, w: f64, h: f64, drill: f64| -> usize {
let k = (shape.to_string(), (w * 1000.0).round() / 1000.0, (h * 1000.0).round() / 1000.0, (drill * 1000.0).round() / 1000.0);
match pad_defs.iter().position(|p| *p == k) { Some(i) => i, None => { pad_defs.push(k); pad_defs.len() - 1 } }
};
// shapes: one per footprint pattern and pad geometry. Two parts share a shape only when their pins match in the
// footprint's frame: two different footprints of the same name (from two libraries) get NAME and NAME_2 (#17)
struct Pin { name: String, pad: usize, x: f64, y: f64, rot: f64, through: bool }
let mut shapes: Vec<(String, Vec<Pin>, bool)> = Vec::new();
let mut shape_of: Vec<(String, String)> = Vec::new(); // ref -> shape name
// the same pins within 5 um: the board model keeps positions to 1 um, so a rotated part's pins come back a micron off
// (BQ25792's R3 against R5: 2.319 against 2.320 mm), and float noise turns 0 into -1e-16
let same_pins = |a: &[Pin], b: &[Pin]| a.len() == b.len() && a.iter().zip(b).all(|(p, q)| p.name == q.name && p.pad == q.pad
&& (p.x - q.x).abs() < 0.005 && (p.y - q.y).abs() < 0.005 && (p.rot - q.rot).abs() < 0.05);
for c in &comps {
let rf = c["ref"].as_str().unwrap_or("").to_string();
let pattern = c["pattern"].as_str().unwrap_or("").to_string();
let base = if pattern.is_empty() { format!("SHAPE_{rf}") } else { pattern.clone() };
let (cx, cy, th) = (f(&c["x"]), f(&c["y"]), f(&c["rotation"]).to_radians());
let bottom = c["side"] == "bottom";
let mut pins = Vec::new();
let mut seen: Vec<String> = Vec::new();
for p in pads.iter().filter(|p| p["ref"].as_str() == Some(&rf)) {
let (dx, dy) = (f(&p["x"]) - cx, f(&p["y"]) - cy);
let (mut lx, ly) = (dx * th.cos() + dy * th.sin(), -dx * th.sin() + dy * th.cos());
if bottom { lx = -lx; }
let mut rot = (f(&p["rotation"]) - f(&c["rotation"])).rem_euclid(360.0);
if bottom { rot = (360.0 - rot).rem_euclid(360.0); }
let (mut w, mut h) = (f(&p["sizeX"]), f(&p["sizeY"]));
// the pad's own size along the footprint's axes
if (rot - 90.0).abs() < 0.5 || (rot - 270.0).abs() < 0.5 { std::mem::swap(&mut w, &mut h); rot = 0.0; }
if rot.abs() < 0.5 || (rot - 180.0).abs() < 0.5 { rot = 0.0; }
let through = f(&p["drill"]) > 0.0;
let mut nm = p["name"].as_str().unwrap_or("").to_string();
if unique_pins && seen.contains(&nm) {
let mut k = 2;
while seen.contains(&format!("{nm}_{k}")) { k += 1; }
nm = format!("{nm}_{k}");
}
seen.push(nm.clone());
let id = pad_name(p["shape"].as_str().unwrap_or("rect"), w, h, f(&p["drill"]));
pins.push(Pin { name: nm, pad: id, x: lx, y: ly, rot, through });
}
// the same pattern with the same pins: reuse that shape; the same name with other pins: a new name
if let Some(existing) = shapes.iter().find(|(n, ps, _)| (*n == base || n.starts_with(&format!("{base}_"))) && same_pins(ps, &pins)) {
shape_of.push((rf, existing.0.clone()));
continue;
}
let mut name = base.clone();
let mut k = 2;
while shapes.iter().any(|x| x.0 == name) { name = format!("{base}_{k}"); k += 1; }
shape_of.push((rf, name.clone()));
let th_part = pins.iter().any(|p| p.through);
shapes.push((name, pins, th_part));
}
// vias: one pad per size and drill
let vias = arr("vias");
let via_pad: Vec<usize> = vias.iter().map(|v| pad_name("round", f(&v["size"]), f(&v["size"]), f(&v["drill"]))).collect();
o += "$PADS\n";
for (i, (shape, w, h, drill)) in pad_defs.iter().enumerate() {
let d = if *drill > 0.0 { inch(*drill) } else { "0".into() };
if shape == "round" && (w - h).abs() < 1e-6 {
o += &format!("PAD P{i} ROUND {d}\nCIRCLE 0 0 {}\n", inch(w / 2.0));
} else if shape == "round" {
o += &format!("PAD P{i} FINGER {d}\nRECTANGLE {} {} {} {}\n", inch(-w / 2.0), inch(-h / 2.0), inch(*w), inch(*h));
} else {
o += &format!("PAD P{i} RECTANGULAR {d}\nRECTANGLE {} {} {} {}\n", inch(-w / 2.0), inch(-h / 2.0), inch(*w), inch(*h));
}
}
o += "$ENDPADS\n$PADSTACKS\n";
let coppers: Vec<String> = {
let mut ls: Vec<String> = arr("tracks").iter().chain(pads.iter()).filter_map(|x| x["layer"].as_str()).filter(|l| is_copper(l)).map(str::to_string).collect();
ls.push("F.Cu".into()); ls.push("B.Cu".into());
ls.sort_by_key(|l| match l.as_str() { "F.Cu" => 0, "B.Cu" => 999, x => x.trim_start_matches("In").trim_end_matches(".Cu").parse::<i32>().unwrap_or(500) });
ls.dedup();
ls
};
for (i, (_, _, _, drill)) in pad_defs.iter().enumerate() {
let d = if *drill > 0.0 { inch(*drill) } else { "0".into() };
o += &format!("PADSTACK PS{i} {d}\n");
if *drill > 0.0 {
for l in &coppers { o += &format!("PAD P{i} {} 0 0\n", gencad_layer(l)); }
} else {
o += &format!("PAD P{i} TOP 0 0\n");
}
}
o += "$ENDPADSTACKS\n$SHAPES\n";
for (name, pins, th) in &shapes {
o += &format!("SHAPE {}\nINSERT {}\n", q(name), if *th { "TH" } else { "SMD" });
for p in pins {
o += &format!("PIN {} PS{} {} {} TOP {} 0\n", q(&p.name), p.pad, inch(p.x), inch(p.y), p.rot);
}
}
o += "$ENDSHAPES\n$COMPONENTS\n";
let mut devices: Vec<String> = Vec::new();
for c in &comps {
let rf = c["ref"].as_str().unwrap_or("");
let shape = shape_of.iter().find(|s| s.0 == rf).map(|s| s.1.clone()).unwrap_or_default();
let bottom = c["side"] == "bottom";
let dev = format!("DEV_{shape}");
if !devices.contains(&dev) { devices.push(dev.clone()); }
o += &format!("COMPONENT {}\nDEVICE {}\nPLACE {} {}\nLAYER {}\nROTATION {}\nSHAPE {} {} {}\n",
q(rf), q(&dev), inch(f(&c["x"])), inch(f(&c["y"])), if bottom { "BOTTOM" } else { "TOP" }, f(&c["rotation"]), q(&shape),
if bottom { "MIRRORX" } else { "0" }, if bottom { "FLIP" } else { "0" });
}
o += "$ENDCOMPONENTS\n$DEVICES\n";
for d in &devices { o += &format!("DEVICE {}\nPACKAGE {}\n", q(d), q(d.trim_start_matches("DEV_"))); }
o += "$ENDDEVICES\n$SIGNALS\n";
let mut nets: Vec<String> = pads.iter().filter_map(|p| p["net"].as_str()).filter(|n| !n.is_empty()).map(str::to_string).collect();
nets.sort(); nets.dedup();
for n in &nets {
o += &format!("SIGNAL {}\n", q(n));
for p in pads.iter().filter(|p| p["net"].as_str() == Some(n) && !p["ref"].as_str().unwrap_or("").is_empty()) {
o += &format!("NODE {} {}\n", q(p["ref"].as_str().unwrap_or("")), q(p["name"].as_str().unwrap_or("")));
}
}
o += "$ENDSIGNALS\n";
// tracks by width, then each net's copper
let tracks: Vec<Value> = arr("tracks").into_iter().filter(|t| t["layer"].as_str().map(is_copper).unwrap_or(false)).collect();
let arcs: Vec<Value> = arr("arcs").into_iter().filter(|t| t["layer"].as_str().map(is_copper).unwrap_or(false)).collect();
let mut widths: Vec<f64> = tracks.iter().chain(arcs.iter()).map(|t| (f(&t["width"]) * 1000.0).round() / 1000.0).collect();
widths.sort_by(|a, b| a.partial_cmp(b).unwrap()); widths.dedup();
o += "$TRACKS\n";
for (i, w) in widths.iter().enumerate() { o += &format!("TRACK T{i} {}\n", inch(*w)); }
o += "$ENDTRACKS\n$ROUTES\n";
let code = |w: f64| widths.iter().position(|x| (*x - (w * 1000.0).round() / 1000.0).abs() < 1e-9).unwrap_or(0);
let mut route_nets: Vec<String> = tracks.iter().chain(arcs.iter()).filter_map(|t| t["net"].as_str()).chain(vias.iter().filter_map(|v| v["net"].as_str())).filter(|n| !n.is_empty()).map(str::to_string).collect();
route_nets.sort(); route_nets.dedup();
let mut unrouted_copper = 0;
for t in tracks.iter().chain(arcs.iter()) { if t["net"].as_str().unwrap_or("").is_empty() { unrouted_copper += 1; } }
for n in &route_nets {
o += &format!("ROUTE {}\n", q(n));
for t in tracks.iter().filter(|t| t["net"].as_str() == Some(n)) {
o += &format!("TRACK T{}\nLAYER {}\nLINE {} {} {} {}\n", code(f(&t["width"])), gencad_layer(t["layer"].as_str().unwrap_or("F.Cu")),
inch(f(&t["start"]["x"])), inch(f(&t["start"]["y"])), inch(f(&t["end"]["x"])), inch(f(&t["end"]["y"])));
}
for a in arcs.iter().filter(|a| a["net"].as_str() == Some(n)) {
let (c, r) = ((f(&a["center"]["x"]), f(&a["center"]["y"])), f(&a["radius"]));
let p = |deg: f64| (c.0 + r * deg.to_radians().cos(), c.1 + r * deg.to_radians().sin());
let (s, e) = (p(f(&a["startAngle"])), p(f(&a["endAngle"])));
o += &format!("TRACK T{}\nLAYER {}\nARC {} {} {} {} {} {}\n", code(f(&a["width"])), gencad_layer(a["layer"].as_str().unwrap_or("F.Cu")),
inch(s.0), inch(s.1), inch(e.0), inch(e.1), inch(c.0), inch(c.1));
}
for (i, v) in vias.iter().enumerate().filter(|(_, v)| v["net"].as_str() == Some(n)) {
// as KiCad writes it: the padstack, the position, the layers and the drill
o += &format!("VIA PS{} {} {} ALL {} VIA{i}\n", via_pad[i], inch(f(&v["position"]["x"])), inch(f(&v["position"]["y"])), inch(f(&v["drill"])));
}
}
o += "$ENDROUTES\n";
let counts = json!({"components": comps.len(), "shapes": shapes.len(), "pads": pad_defs.len(), "signals": nets.len(), "routes": route_nets.len(),
"vias": vias.len(), "copperWithoutNet": unrouted_copper});
(o, counts)
}
/// writers::gencad: `{board, args: {title?, uniquePins?}}` -> the GenCAD text and what it holds.
pub fn gencad(input: &Value) -> Value {
let board = match crate::input::board(input) { Ok(b) => b, Err(e) => return e };
let args = input.get("args").cloned().unwrap_or(json!({}));
let title = args.get("title").and_then(|v| v.as_str()).unwrap_or("board");
let (text, counts) = gencad_text(&board, title, args.get("uniquePins").and_then(|v| v.as_bool()) == Some(true));
json!({"success": true, "text": text, "counts": counts, "units": "in", "writtenFrom": "board model",
"_hint": "GenCAD 1.4 written from the board model, not the tool's own exporter. copperWithoutNet counts tracks on no net, which no route can carry."})
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::dxf::tests::lane;
#[test]
fn gencad_has_every_section_and_the_lane_parts() {
let Some(st) = lane() else { return };
let (t, c) = gencad_text(&st, "lane", false);
for s in ["HEADER", "BOARD", "PADS", "PADSTACKS", "SHAPES", "COMPONENTS", "DEVICES", "SIGNALS", "TRACKS", "ROUTES"] {
assert_eq!(t.matches(&format!("${s}\n")).count(), 1, "{s}");
assert_eq!(t.matches(&format!("$END{s}\n")).count(), 1, "{s}");
}
assert_eq!(c["components"], json!(st["components"].as_array().unwrap().len()));
assert_eq!(t.matches("\nCOMPONENT ").count(), st["components"].as_array().unwrap().len());
// the outline: four lines and four corner arcs, a 40 x 20 mm board in inches
assert_eq!(t.split("$ENDBOARD").next().unwrap().matches("\nARC ").count(), 4);
assert!(t.contains(&format!("{:.6}", 40.0 / 25.4)));
// every pin of a SOIC-8 sits in its own frame: x is +-2.7 mm whatever the part's rotation
let shape = t.split("SHAPE \"TST_SOIC8\"\n").nth(1).unwrap().split("SHAPE ").next().unwrap();
assert_eq!(shape.matches("PIN ").count(), 8);
for l in shape.lines().filter(|l| l.starts_with("PIN ")) {
let x: f64 = l.split_whitespace().nth(3).unwrap().parse().unwrap();
assert!((x.abs() * 25.4 - 2.7).abs() < 0.01, "{l}");
}
}
#[test]
fn duplicate_pin_names_are_made_unique_on_request() {
let st = json!({"components": [{"ref": "J1", "pattern": "TAB", "x": 0.0, "y": 0.0, "rotation": 0.0, "side": "top"}],
"pads": [{"ref": "J1", "name": "1", "x": 0.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "shape": "rect", "drill": 0.0, "rotation": 0.0, "layer": "F.Cu", "net": ""},
{"ref": "J1", "name": "1", "x": 2.0, "y": 0.0, "sizeX": 1.0, "sizeY": 1.0, "shape": "rect", "drill": 0.0, "rotation": 0.0, "layer": "F.Cu", "net": ""}]});
assert_eq!(gencad_text(&st, "x", false).0.matches("PIN \"1\" ").count(), 2);
let (t, _) = gencad_text(&st, "x", true);
assert!(t.contains("PIN \"1\" ") && t.contains("PIN \"1_2\" "));
}
#[test]
fn bottom_parts_and_vias_read_as_kicad_writes_them() {
let st = json!({"components": [{"ref": "R1", "pattern": "R0603", "x": 10.0, "y": 5.0, "rotation": 180.0, "side": "bottom"}],
"pads": [{"ref": "R1", "name": "1", "x": 10.825, "y": 5.0, "sizeX": 0.8, "sizeY": 0.95, "shape": "rect", "drill": 0.0, "rotation": 180.0, "layer": "B.Cu", "net": "A"}],
"vias": [{"net": "A", "drill": 0.3, "size": 0.6, "layers": ["F.Cu", "B.Cu"], "position": {"x": 10.0, "y": 7.0}}]});
let (t, _) = gencad_text(&st, "x", false);
assert!(t.contains("LAYER BOTTOM\nROTATION 180\nSHAPE \"R0603\" MIRRORX FLIP\n"), "{t}");
// pad 1 is 0.825 mm left of the part's centre in its own frame (rotated 180, then mirrored back to the top view)
assert!(t.contains(&format!("PIN \"1\" PS0 {:.6} 0.000000 TOP", 0.825 / 25.4)) || t.contains(&format!("PIN \"1\" PS0 {:.6} -0.000000 TOP", 0.825 / 25.4)), "{t}");
assert!(t.contains(&format!("ALL {:.6} VIA0", 0.3 / 25.4)), "{t}");
}
#[test]
fn the_writer_entry_says_where_it_came_from() {
let r = gencad(&json!({"board": {"components": [], "pads": [], "outlineEdges": []}, "args": {"title": "t"}}));
assert_eq!(r["success"], true);
assert_eq!(r["writtenFrom"], "board model");
assert!(r["text"].as_str().unwrap().starts_with("$HEADER\nGENCAD 1.4\n"));
}
#[test]
fn same_named_footprints_with_other_pads_are_two_shapes() {
let pad = |r: &str, n: &str, x: f64, w: f64| json!({"ref": r, "name": n, "x": x, "y": 0.0, "sizeX": w, "sizeY": 1.0, "shape": "rect", "drill": 0.0, "rotation": 0.0, "layer": "F.Cu", "net": ""});
// R1 and R2: the same "R0603" footprint (R2 rotated 180): one shape. R3: another library's "R0603", other pads: its own
let st = json!({"components": [{"ref": "R1", "pattern": "R0603", "x": 0.0, "y": 0.0, "rotation": 0.0, "side": "top"},
{"ref": "R2", "pattern": "R0603", "x": 10.0, "y": 0.0, "rotation": 180.0, "side": "top"},
{"ref": "R3", "pattern": "R0603", "x": 20.0, "y": 0.0, "rotation": 0.0, "side": "top"}],
"pads": [pad("R1", "1", -0.8, 0.9), pad("R1", "2", 0.8, 0.9), pad("R2", "1", 10.801, 0.9), pad("R2", "2", 9.2, 0.9),
pad("R3", "1", 19.0, 1.2), pad("R3", "2", 21.0, 1.2)]});
let (t, _) = gencad_text(&st, "x", false);
assert_eq!(t.matches("SHAPE \"R0603\"\nINSERT").count(), 1, "R1 and R2 share one shape");
assert!(t.contains("SHAPE \"R0603_2\"\nINSERT"), "R3's footprint is its own shape");
assert!(!t.contains("R0603_3"), "R2 reuses R1's shape");
// each part points at its shape
let comp = |r: &str| t.split(&format!("COMPONENT \"{r}\"\n")).nth(1).unwrap().lines().find(|l| l.starts_with("SHAPE ")).unwrap().to_string();
assert!(comp("R1").starts_with("SHAPE \"R0603\"") && comp("R2").starts_with("SHAPE \"R0603\"") && comp("R3").starts_with("SHAPE \"R0603_2\""));
}
}