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 #313: kicad-lane: 1.0.68, BOM, JLCPCB assembly and order, land-pattern footprints, designators
e1c2dac
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
//! Two board reads the EDA Bridge Standard names in its core tier, from the board's text (the live
//! editor's serialisation or the file): `layers`, the board's whole layer set, and `silk_state`,
//! the silkscreen as it stands. Millimetres; y up unless `y_up` is false (KiCad's own frame).
use serde_json::{json, Value};
use crate::placement::to_board;
use crate::schematic::{Document, Node};
fn root(text: &str) -> Result<Node, String> {
let doc = Document::parse(text)?;
if doc.root.head() != Some("kicad_pcb") {
return Err("not a board (the top-level list is not kicad_pcb)".into());
}
Ok(doc.root)
}
/// Every layer the board defines: KiCad's id, name, kind (signal, power, mixed, jumper, user) and
/// the user's name for it. `visible`, when the live editor said which layers it shows.
pub fn layers(text: &str, visible: Option<&[String]>) -> Result<Value, String> {
let r = root(text)?;
let list = r.child("layers").ok_or("the board has no (layers ..) table")?;
let rows: Vec<Value> = list
.children()
.iter()
.skip(1)
.filter(|c| c.is_list())
.map(|c| {
let name = c.arg(0).unwrap_or_default();
let kind = c.arg(1).unwrap_or_default();
json!({
"id": c.head().and_then(|h| h.parse::<i64>().ok()),
"name": name,
"kind": kind,
"copper": name.ends_with(".Cu"),
"userName": c.arg(2),
"displayed": visible.map(|v| v.iter().any(|x| x == &name)),
})
})
.collect();
Ok(json!({"count": rows.len(), "copper": rows.iter().filter(|r| r["copper"] == true).count(), "layers": rows}))
}
fn pt(n: &Node, tag: &str) -> Option<(f64, f64)> {
let p = n.child(tag)?;
Some((p.num(0)?, p.num(1)?))
}
/// Board point in the requested frame.
fn out(p: (f64, f64), y_up: bool) -> Value {
json!([round(p.0), round(if y_up { -p.1 } else { p.1 })])
}
fn round(v: f64) -> f64 {
(v * 1e4).round() / 1e4 + 0.0
}
/// A footprint field (or legacy fp_text) as the silkscreen shows it.
fn field(f: &Node, origin: (f64, f64), rot: f64, y_up: bool) -> Value {
let at = f.child("at");
let local = at.and_then(|a| Some((a.num(0)?, a.num(1)?))).unwrap_or((0.0, 0.0));
let font = f.child("effects").and_then(|e| e.child("font"));
let size = font.and_then(|ft| ft.child("size"));
let hidden = f.child_value("hide").as_deref() == Some("yes") || f.child("effects").map(|e| e.children().iter().any(|c| c.raw() == Some("hide"))).unwrap_or(false);
json!({
"text": f.arg(1).or_else(|| f.arg(0)),
"id": f.child_value("uuid"),
"layer": f.child_value("layer"),
"at": out(to_board(origin, rot, local), y_up),
"rotation": at.and_then(|a| a.num(2)).unwrap_or(0.0),
"heightMm": size.and_then(|s| s.num(0)),
"widthMm": size.and_then(|s| s.num(1)),
"thicknessMm": font.and_then(|ft| ft.child("thickness")).and_then(|t| t.num(0)),
"shown": !hidden,
})
}
fn on_silk(n: &Node) -> bool {
matches!(n.child_value("layer").as_deref(), Some("F.SilkS") | Some("B.SilkS"))
}
/// Every point a graphic draws through (start/mid/end/center, a polygon's points), board frame.
fn graphic_points(g: &Node) -> Vec<(f64, f64)> {
let mut p: Vec<(f64, f64)> = ["start", "mid", "end", "center"].iter().filter_map(|t| pt(g, t)).collect();
if let Some(pts) = g.child("pts") {
p.extend(pts.children().iter().filter(|c| c.head() == Some("xy")).filter_map(|c| Some((c.num(0)?, c.num(1)?))));
}
p
}
/// The points that bound a graphic: its own points, except that a circle (its centre and one point
/// on it) is bounded by the centre plus and minus the radius.
fn extent_points(g: &Node) -> Vec<(f64, f64)> {
if g.head().map_or(false, |h| h.ends_with("circle")) {
if let (Some(c), Some(e)) = (pt(g, "center"), pt(g, "end")) {
let r = ((e.0 - c.0).powi(2) + (e.1 - c.1).powi(2)).sqrt();
return vec![(c.0 - r, c.1 - r), (c.0 + r, c.1 + r), (c.0 - r, c.1 + r), (c.0 + r, c.1 - r)];
}
}
graphic_points(g)
}
fn bbox(points: &[(f64, f64)]) -> Option<[f64; 4]> {
let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
for (x, y) in points {
x0 = x0.min(*x);
y0 = y0.min(*y);
x1 = x1.max(*x);
y1 = y1.max(*y);
}
(x0 <= x1).then_some([x0, y0, x1, y1])
}
/// The silkscreen as it stands: every part's body box (its fab drawing, else its courtyard), its
/// designator and value as placed (text, position, size, rotation, shown), and the free texts and
/// graphics on both silkscreen layers.
pub fn silk_state(text: &str, y_up: bool) -> Result<Value, String> {
let r = root(text)?;
let mut parts = Vec::new();
// the standard's shape beside KiCad's: components, and every silkscreen line segment (footprints' own included)
let mut components = Vec::new();
let mut lines = Vec::new();
for fp in r.children().iter().filter(|c| c.head() == Some("footprint")) {
let at = fp.child("at");
let (x, y, rot) = (at.and_then(|a| a.num(0)).unwrap_or(0.0), at.and_then(|a| a.num(1)).unwrap_or(0.0), at.and_then(|a| a.num(2)).unwrap_or(0.0));
let side = fp.child_value("layer").unwrap_or_default();
let fab = if side.starts_with('B') { "B.Fab" } else { "F.Fab" };
let crt = if side.starts_with('B') { "B.CrtYd" } else { "F.CrtYd" };
let pts_on = |layer: &str| -> Vec<(f64, f64)> {
fp.children()
.iter()
.filter(|c| c.head().map(|h| h.starts_with("fp_")).unwrap_or(false) && c.head() != Some("fp_text") && c.child_value("layer").as_deref() == Some(layer))
.flat_map(extent_points)
.map(|p| to_board((x, y), rot, p))
.collect()
};
let mut body = pts_on(fab);
let from = if body.is_empty() {
body = pts_on(crt);
"courtyard"
} else {
"fab"
};
let prop = |name: &str| fp.children().iter().find(|c| (c.head() == Some("property") && c.arg(0).as_deref() == Some(name)) || (c.head() == Some("fp_text") && c.arg(0).as_deref() == Some(if name == "Reference" { "reference" } else { "value" })));
let bx = bbox(&body).map(|b| {
let (a, c) = (out((b[0], b[1]), y_up), out((b[2], b[3]), y_up));
let (ay, cy) = (a[1].as_f64().unwrap_or(0.0), c[1].as_f64().unwrap_or(0.0));
json!([a[0], ay.min(cy), c[0], ay.max(cy)])
});
for g in fp.children().iter().filter(|c| c.head().map(|h| h.starts_with("fp_")).unwrap_or(false) && c.head() != Some("fp_text") && on_silk(c)) {
let pts: Vec<(f64, f64)> = segment_points(g).into_iter().map(|p| to_board((x, y), rot, p)).collect();
push_lines(&mut lines, g, &pts, y_up, "footprint");
}
let std_field = |f: Option<&Node>| f.map(|f| {
let v = field(f, (x, y), rot, y_up);
json!({"text": v["text"], "x": v["at"][0], "y": v["at"][1], "size": v["heightMm"], "rotation": v["rotation"], "shown": v["shown"]})
});
components.push(json!({
"ref": prop("Reference").and_then(|f| f.arg(1)), "pattern": fp.arg(0), "x": out((x, y), y_up)[0], "y": out((x, y), y_up)[1], "rotation": rot,
"side": if side.starts_with('B') { "bottom" } else { "top" }, "body": bx,
"name": std_field(prop("Reference")), "comment": std_field(prop("Value")),
}));
parts.push(json!({
"ref": prop("Reference").and_then(|f| f.arg(1)),
"side": if side.starts_with('B') { "bottom" } else { "top" },
"at": out((x, y), y_up),
"rotation": rot,
"bodyBox": bx,
"bodyFrom": if bx.is_some() { json!(from) } else { Value::Null },
"designator": prop("Reference").map(|f| field(f, (x, y), rot, y_up)),
"comment": prop("Value").map(|f| field(f, (x, y), rot, y_up)),
}));
}
let mut texts = Vec::new();
let mut graphics = Vec::new();
for c in r.children().iter().filter(|c| on_silk(c)) {
match c.head() {
Some("gr_text") => {
let at = c.child("at");
let size = c.child("effects").and_then(|e| e.child("font")).and_then(|f| f.child("size"));
let at_v = out(at.and_then(|a| Some((a.num(0)?, a.num(1)?))).unwrap_or((0.0, 0.0)), y_up);
let layer = c.child_value("layer");
texts.push(json!({
"id": c.child_value("uuid"), "text": c.arg(0), "layer": layer,
"at": at_v, "rotation": at.and_then(|a| a.num(2)).unwrap_or(0.0), "heightMm": size.and_then(|s| s.num(0)),
// the standard's names for the same
"side": if layer.as_deref() == Some("B.SilkS") { "bottom" } else { "top" }, "x": at_v[0], "y": at_v[1], "size": size.and_then(|s| s.num(0)),
}));
}
Some(h) if h.starts_with("gr_") => {
push_lines(&mut lines, c, &segment_points(c), y_up, "board");
graphics.push(json!({
"id": c.child_value("uuid"), "kind": h.trim_start_matches("gr_"), "layer": c.child_value("layer"),
"points": graphic_points(c).into_iter().map(|p| out(p, y_up)).collect::<Vec<_>>(),
"widthMm": c.child("stroke").and_then(|s| s.child("width")).and_then(|w| w.num(0)),
}));
}
_ => {}
}
}
Ok(json!({"frame": if y_up { "standard" } else { "kicad" }, "parts": parts, "texts": texts, "graphics": graphics,
"components": components, "lines": lines,
"counts": {"parts": parts.len(), "texts": texts.len(), "graphics": graphics.len(), "lines": lines.len()}}))
}
/// A silkscreen graphic's outline as a chain of points in its own frame: a line's ends, a rectangle's
/// four corners closed, a polygon's points closed, an arc through start, mid and end, a circle as a
/// 16-gon.
fn segment_points(g: &Node) -> Vec<(f64, f64)> {
match g.head().unwrap_or("").trim_start_matches("fp_").trim_start_matches("gr_") {
"line" => ["start", "end"].iter().filter_map(|t| pt(g, t)).collect(),
"arc" => ["start", "mid", "end"].iter().filter_map(|t| pt(g, t)).collect(),
"rect" => match (pt(g, "start"), pt(g, "end")) {
(Some(a), Some(b)) => vec![a, (b.0, a.1), b, (a.0, b.1), a],
_ => Vec::new(),
},
"circle" => match (pt(g, "center"), pt(g, "end")) {
(Some(c), Some(e)) => {
let r = ((e.0 - c.0).powi(2) + (e.1 - c.1).powi(2)).sqrt();
(0..=16).map(|i| { let t = i as f64 * std::f64::consts::PI / 8.0; (c.0 + r * t.cos(), c.1 + r * t.sin()) }).collect()
}
_ => Vec::new(),
},
"poly" => {
let mut p: Vec<(f64, f64)> = g.child("pts").map(|pts| pts.children().iter().filter(|c| c.head() == Some("xy")).filter_map(|c| Some((c.num(0)?, c.num(1)?))).collect()).unwrap_or_default();
if let Some(first) = p.first().copied() { p.push(first); }
p
}
_ => Vec::new(),
}
}
/// The standard's silk lines: one {side, x1, y1, x2, y2, width, raw} per segment of `pts` (board frame).
fn push_lines(lines: &mut Vec<Value>, g: &Node, pts: &[(f64, f64)], y_up: bool, raw: &str) {
let side = if g.child_value("layer").as_deref() == Some("B.SilkS") { "bottom" } else { "top" };
let width = g.child("stroke").and_then(|s| s.child("width")).and_then(|w| w.num(0)).or_else(|| g.child("width").and_then(|w| w.num(0)));
for w in pts.windows(2) {
let (a, b) = (out(w[0], y_up), out(w[1], y_up));
lines.push(json!({"side": side, "x1": a[0], "y1": a[1], "x2": b[0], "y2": b[1], "width": width, "raw": raw}));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn board() -> String {
std::fs::read_to_string(std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/konnect-route/route-fixture.kicad_pcb")).unwrap()
}
#[test]
fn the_layer_table_reads_with_kinds() {
let v = layers(&board(), Some(&["F.Cu".to_string()])).unwrap();
let f = v["layers"].as_array().unwrap().iter().find(|l| l["name"] == "F.Cu").unwrap().clone();
assert_eq!(f["copper"], true);
assert_eq!(f["displayed"], true);
assert!(v["copper"].as_u64().unwrap() >= 2, "{v}");
}
#[test]
fn silk_state_places_the_designator_on_the_board() {
let v = silk_state(&board(), false).unwrap();
let u1 = v["parts"].as_array().unwrap().iter().find(|p| p["ref"] == "U1").cloned().unwrap();
// U1 sits at (20, 20) with its Reference 3 mm above, in KiCad's frame
assert_eq!(u1["designator"]["at"], json!([20.0, 17.0]), "{u1}");
assert_eq!(u1["designator"]["text"], "U1");
assert!(u1["bodyBox"].is_array(), "{u1}");
let up = silk_state(&board(), true).unwrap();
let u1 = up["parts"].as_array().unwrap().iter().find(|p| p["ref"] == "U1").cloned().unwrap();
assert_eq!(u1["designator"]["at"], json!([20.0, -17.0]));
}
#[test]
fn a_round_courtyard_is_a_square_body_box() {
let text = "(kicad_pcb (footprint \"TestPoint:TP\" (layer \"F.Cu\") (at 10 10 0) (property \"Reference\" \"TP1\" (at 0 -2 0) (layer \"F.SilkS\"))\n (fp_circle (center 0 0) (end 1 0) (layer \"F.CrtYd\"))))";
let v = silk_state(text, false).unwrap();
assert_eq!(v["parts"][0]["bodyBox"], json!([9.0, 9.0, 11.0, 11.0]), "{v}");
}
#[test]
fn silk_state_also_answers_the_standards_shape() {
// the standard's silk_state: components with name {text, x, y, size, shown}, lines with a width
let up = silk_state(&board(), true).unwrap();
let u1 = up["components"].as_array().unwrap().iter().find(|c| c["ref"] == "U1").cloned().unwrap();
assert_eq!((u1["name"]["text"].as_str(), u1["name"]["x"].as_f64(), u1["name"]["y"].as_f64()), (Some("U1"), Some(20.0), Some(-17.0)), "{u1}");
assert!(u1["name"]["size"].as_f64().unwrap() > 0.0 && u1["side"] == "top");
let lines = up["lines"].as_array().unwrap();
assert!(lines.iter().all(|l| l["x1"].is_number() && l["y2"].is_number() && (l["side"] == "top" || l["side"] == "bottom")), "{lines:?}");
}
}