skill
EDA Bridge Standard: one way for an AI to drive every PCB tool
Public Made by Adomby adom
The standard every Adom EDA bridge follows so one AI can drive Altium, KiCad, Fusion 360 and any future PCB tool the same way: shared verb names in tiers, the board model JSON, reply and refusal conventions, and a coverage table of each bridge against the Altium reference.
main
Colby Knox
standard 0.5.1, engine 0.2.1: GenCAD keeps one shape per footprint pattern and pad geometry (same name, other pads: NAM…
ec6beb6
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\""));
}
}