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 #318: kicad-lane: 1.0.71, library_views and set_layer_visibility
febd39d
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
//! Pictures of library parts (the EDA Bridge Standard's library_views), the pure half: the device
//! view (KiCad has no device; it is the symbol's pins mapped to the footprint's pads, drawn as an
//! SVG table) and the one-part board the 3D view renders. The verb runs kicad-cli for the symbol
//! and footprint SVGs and the 3D render.
use serde_json::{json, Value};
/// Every pin of the symbol (number, name) against the footprint's pad numbers: the rows, the pins
/// with no pad and the pads with no pin. Pads without a number (mounting holes, NPTH) are skipped.
pub fn pin_map(pins: &[Value], pads: &[Value]) -> Value {
let pad_nums: Vec<String> = pads.iter().filter_map(|p| p["number"].as_str()).filter(|n| !n.is_empty()).map(String::from).collect();
let mut rows = Vec::new();
let mut pin_nums: Vec<String> = Vec::new();
for p in pins {
let Some(n) = p["number"].as_str() else { continue };
if pin_nums.iter().any(|m| m == n) {
continue;
}
pin_nums.push(n.to_string());
rows.push(json!({"pin": n, "name": p["name"].as_str().unwrap_or(""), "pad": pad_nums.iter().any(|q| q == n)}));
}
rows.sort_by_key(|r| crate::placement::ref_key(r["pin"].as_str().unwrap_or("")));
let mut no_pad: Vec<&str> = rows.iter().filter(|r| r["pad"] == false).filter_map(|r| r["pin"].as_str()).collect();
no_pad.sort_by_key(|n| crate::placement::ref_key(n));
let mut no_pin: Vec<String> = pad_nums.iter().filter(|q| !pin_nums.contains(q)).cloned().collect();
no_pin.sort_by_key(|n| crate::placement::ref_key(n));
no_pin.dedup();
json!({"rows": rows, "pinsWithoutPad": no_pad, "padsWithoutPin": no_pin, "ok": no_pad.is_empty() && no_pin.is_empty()})
}
fn esc(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
}
/// The device view: a table of pin, pin name and pad, a missing pad in red, then the pads no pin
/// lands on.
pub fn device_svg(part: &str, symbol: &str, footprint: &str, map: &Value) -> String {
let rows = map["rows"].as_array().cloned().unwrap_or_default();
let extra = map["padsWithoutPin"].as_array().cloned().unwrap_or_default();
let (row_h, w) = (22.0, 520.0);
let h = 96.0 + row_h * (rows.len() as f64 + if extra.is_empty() { 0.0 } else { 2.0 });
let mut s = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w}\" height=\"{h}\" viewBox=\"0 0 {w} {h}\" font-family=\"Segoe UI, Arial, sans-serif\" font-size=\"14\">\n<rect width=\"100%\" height=\"100%\" fill=\"#ffffff\"/>\n");
s += &format!("<text x=\"16\" y=\"26\" font-size=\"17\" font-weight=\"bold\">{}</text>\n", esc(part));
s += &format!("<text x=\"16\" y=\"46\" fill=\"#555\">{} → {}</text>\n", esc(symbol), esc(footprint));
s += "<text x=\"16\" y=\"78\" font-weight=\"bold\">Pin</text><text x=\"96\" y=\"78\" font-weight=\"bold\">Name</text><text x=\"380\" y=\"78\" font-weight=\"bold\">Pad</text>\n";
let mut y = 78.0;
for r in &rows {
y += row_h;
let ok = r["pad"] == true;
let pin = esc(r["pin"].as_str().unwrap_or(""));
s += &format!("<text x=\"16\" y=\"{y}\">{pin}</text><text x=\"96\" y=\"{y}\">{}</text><text x=\"380\" y=\"{y}\" fill=\"{}\">{}</text>\n",
esc(r["name"].as_str().unwrap_or("")), if ok { "#1a7f37" } else { "#cf222e" }, if ok { pin.clone() } else { "no pad".into() });
}
if !extra.is_empty() {
y += row_h * 1.5;
let list: Vec<&str> = extra.iter().filter_map(Value::as_str).collect();
s += &format!("<text x=\"16\" y=\"{y}\" fill=\"#cf222e\">Pads with no pin: {}</text>\n", esc(&list.join(", ")));
}
s += "</svg>\n";
s
}
/// The union of the footprint's shown model boxes in its own frame (y up, z up from the surface),
/// `resolved` being each `(model ...)`'s file in order (None: it did not resolve).
pub fn body_box(fp_text: &str, resolved: &[Option<String>]) -> Option<crate::bodies::Bounds> {
let fp = crate::model_check::parse_sexpr(fp_text)?;
let mut out: Option<crate::bodies::Bounds> = None;
for (m, f) in crate::bodies::model_refs(&fp).iter().zip(resolved) {
let Some(f) = f.as_deref().filter(|_| !m.hidden) else { continue };
let Ok(b) = crate::bodies::file_bounds(std::path::Path::new(f)) else { continue };
let b = crate::bodies::in_footprint(&b, m);
out = Some(match out {
None => b,
Some(o) => crate::bodies::Bounds { min: [0, 1, 2].map(|i| o.min[i].min(b.min[i])), max: [0, 1, 2].map(|i| o.max[i].max(b.max[i])) },
});
}
out
}
/// A one-part board for the 3D view, and the camera zoom that keeps the body in the frame: the
/// footprint at the origin of a 2-layer board whose outline is its courtyard, pads and body grown by
/// 1 mm. kicad-cli frames the board, so a body taller than the board is wide zooms out.
pub fn view_board(fp_text: &str, lib_id: &str, body: Option<&crate::bodies::Bounds>) -> Result<(String, f64), String> {
let info = crate::fpedit::info(fp_text)?;
let (mut x0, mut y0, mut x1, mut y1) = match info["courtyard"].as_object() {
Some(c) => (c["min"][0].as_f64().unwrap_or(-1.0), c["min"][1].as_f64().unwrap_or(-1.0), c["max"][0].as_f64().unwrap_or(1.0), c["max"][1].as_f64().unwrap_or(1.0)),
None => (-1.0, -1.0, 1.0, 1.0),
};
for p in info["pads"].as_array().into_iter().flatten() {
let (x, y) = (p["at"][0].as_f64().unwrap_or(0.0), p["at"][1].as_f64().unwrap_or(0.0));
let r = p["size"].as_array().map(|s| s.iter().filter_map(Value::as_f64).fold(0.0, f64::max) / 2.0).unwrap_or(0.0);
(x0, y0, x1, y1) = (x0.min(x - r), y0.min(y - r), x1.max(x + r), y1.max(y + r));
}
let mut height = 0.0;
if let Some(b) = body {
// the body's box is y up; the board's frame is y down
(x0, y0, x1, y1) = (x0.min(b.min[0]), y0.min(-b.max[1]), x1.max(b.max[0]), y1.max(-b.min[1]));
height = b.max[2] - b.min[2].min(0.0);
}
let side = (x1 - x0).max(y1 - y0) + 2.0;
let zoom = (side / (side + 0.9 * height)).clamp(0.3, 0.9);
let blank = crate::project::blank_board(None, 2)?;
let (outline, _) = crate::board_build::outline_items(&json!({"rect": [x0 - 1.0, y0 - 1.0, x1 - x0 + 2.0, y1 - y0 + 2.0]}), false)?;
let part = crate::board_build::Placed { reference: "U1".into(), lib_id: lib_id.into(), text: fp_text.into(), x: 0.0, y: 0.0, rotation: 0.0, back: false, value: None };
let text = crate::board_build::build(&blank, &outline, &[part], &Default::default())?.0;
Ok((text, (zoom * 100.0).round() / 100.0))
}
#[cfg(test)]
mod tests {
use super::*;
const SOT23: &str = "(footprint \"SOT-23\" (layer \"F.Cu\")
(property \"Reference\" \"REF**\" (at 0 -2.4 0) (layer \"F.SilkS\") (uuid \"a\") (effects (font (size 1 1) (thickness 0.15))))
(property \"Value\" \"SOT-23\" (at 0 2.4 0) (layer \"F.Fab\") (uuid \"b\") (effects (font (size 1 1) (thickness 0.15))))
(fp_rect (start -1.9 -1.7) (end 1.9 1.7) (stroke (width 0.05) (type solid)) (fill no) (layer \"F.CrtYd\") (uuid \"c\"))
(pad \"1\" smd roundrect (at -1.1 -0.95) (size 1.3 0.6) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"d\"))
(pad \"2\" smd roundrect (at -1.1 0.95) (size 1.3 0.6) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"e\"))
(pad \"3\" smd roundrect (at 1.1 0) (size 1.3 0.6) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"f\"))
(pad \"\" np_thru_hole circle (at 0 0) (size 0.5 0.5) (drill 0.5) (layers \"*.Cu\" \"*.Mask\") (uuid \"g\"))
(model \"${KICAD10_3DMODEL_DIR}/Package_TO_SOT_SMD.3dshapes/SOT-23.step\" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0))))";
#[test]
fn pins_are_mapped_to_pads_both_ways() {
let pins = vec![json!({"number": "2", "name": "S"}), json!({"number": "1", "name": "G"}), json!({"number": "4", "name": "TAB"}), json!({"number": "1", "name": "G"})];
let pads = crate::fpedit::info(SOT23).unwrap()["pads"].as_array().unwrap().clone();
let m = pin_map(&pins, &pads);
assert_eq!(m["rows"].as_array().unwrap().len(), 3, "a pin repeated across body styles counts once");
assert_eq!(m["rows"][0]["pin"], "1");
assert_eq!(m["pinsWithoutPad"], json!(["4"]));
assert_eq!(m["padsWithoutPin"], json!(["3"]), "the unnumbered NPTH is not a pad without a pin");
assert_eq!(m["ok"], false);
let svg = device_svg("Q<1>", "Lib:Q", "Lib:SOT-23", &m);
assert!(svg.contains("Q<1>") && svg.contains("no pad") && svg.contains("Pads with no pin: 3"));
assert!(crate::sch_render::rasterize(svg.as_bytes(), 260).is_ok(), "the SVG parses");
}
#[test]
fn the_view_board_holds_the_part_inside_an_outline() {
let (t, zoom) = view_board(SOT23, "Lib:SOT-23", None).unwrap();
assert_eq!(zoom, 0.9);
// a body 20 mm tall, wider than the pads: the board covers it and the camera backs off
let tall = crate::bodies::Bounds { min: [-3.0, -2.0, -3.0], max: [3.0, 2.0, 17.0] };
let (t2, z2) = view_board(SOT23, "Lib:SOT-23", Some(&tall)).unwrap();
assert!(t2.contains("(start -4 -3)") && z2 < 0.4, "{z2}");
let parsed = crate::pcb::parse_pcb_text(&t, "v.kicad_pcb").unwrap();
assert_eq!(crate::pcb::pads(&parsed).len(), 4);
assert!(t.contains("Edge.Cuts") && t.contains("SOT-23.step"));
assert!(t.contains("(start -2.9 -2.7)"), "courtyard grown by 1 mm");
}
}