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 #315: kicad-lane: 1.0.69, length tuning, mouse-bite panels, silkscreen prep, component classes, build/test boa…
df9e892
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
//! Component classes on a board (the EDA Bridge Standard's component_classes), as KiCad 9 and 10
//! keep them: each member footprint carries `(component_classes (class "<name>"))`, which DRC rules
//! match with `A.hasComponentClass('<name>')`. A class's room is a rule area named
//! `Adom room <name>` (everything allowed inside it) plus a custom rule asserting that every member
//! footprint lies within it (`A.enclosedByArea('Adom room <name>')`): a member outside is a DRC
//! violation, not a move. Both checked with KiCad 10.0.7's DRC. Pure text edits on the board.
use std::collections::BTreeMap;
use serde_json::{json, Value};
use crate::sxedit::{self, Edits, Node};
pub fn room_name(class: &str) -> String {
format!("Adom room {class}")
}
/// The rule (for the project's .kicad_dru) that keeps a class's members inside its room.
pub fn room_rule(class: &str) -> String {
let n = room_name(class);
format!("(rule \"{n}\"\n\t(condition \"A.Type == 'Footprint' && A.hasComponentClass('{class}')\")\n\t(constraint assertion \"A.enclosedByArea('{n}')\"))")
}
fn reference(fp: &Node) -> Option<&str> {
fp.kids_tagged("property").find(|p| p.atom(1) == Some("Reference")).and_then(|p| p.atom(2))
}
fn classes_of(fp: &Node) -> Vec<String> {
fp.kid("component_classes").map(|c| c.kids_tagged("class").filter_map(|k| k.atom(1).map(String::from)).collect()).unwrap_or_default()
}
/// Every class with its members, and every room (its rule area's box, KiCad's frame).
pub fn list(text: &str) -> Result<Value, String> {
let root = sxedit::parse(text)?;
let mut classes: BTreeMap<String, Vec<String>> = BTreeMap::new();
for fp in root.kids_tagged("footprint") {
let r = reference(fp).unwrap_or("").to_string();
for c in classes_of(fp) {
classes.entry(c).or_default().push(r.clone());
}
}
let mut rooms = Vec::new();
for z in root.kids_tagged("zone") {
if let Some(name) = z.value_of("name").and_then(|n| n.strip_prefix("Adom room ")) {
let mut pts: Vec<(f64, f64)> = Vec::new();
z.walk(&mut |n| if n.tag() == Some("xy") { if let (Some(x), Some(y)) = (n.f64_at(1), n.f64_at(2)) { pts.push((x, y)); } });
let (x0, x1) = pts.iter().fold((f64::MAX, f64::MIN), |m, p| (m.0.min(p.0), m.1.max(p.0)));
let (y0, y1) = pts.iter().fold((f64::MAX, f64::MIN), |m, p| (m.0.min(p.1), m.1.max(p.1)));
rooms.push(json!({"class": name, "rect": [x0, y0, x1, y1]}));
}
}
for m in classes.values_mut() {
m.sort_by_key(|r| crate::placement::ref_key(r));
}
Ok(json!({"classes": classes.iter().map(|(n, m)| json!({"name": n, "members": m})).collect::<Vec<_>>(), "rooms": rooms}))
}
/// The board with `class` taken off every footprint, then put on `members` (the same name replaces
/// the class). Returns the text and the members not on the board.
pub fn set_class(text: &str, class: &str, members: &[String]) -> Result<(String, Vec<String>), String> {
let text = remove_class(text, class)?;
let root = sxedit::parse(&text)?;
let mut e = Edits::new();
let mut found: Vec<String> = Vec::new();
for fp in root.kids_tagged("footprint") {
let Some(r) = reference(fp) else { continue };
if !members.iter().any(|m| m.eq_ignore_ascii_case(r)) {
continue;
}
found.push(r.to_ascii_uppercase());
let entry = format!("(class {})", sxedit::q(class));
match fp.kid("component_classes") {
Some(cc) => e.insert(cc.end - 1, format!(" {entry}")),
None => {
let after = fp.kid("layer").map(|l| l.end).unwrap_or(fp.kids[1].end);
e.insert(after, format!("\n\t\t(component_classes\n\t\t\t{entry}\n\t\t)"));
}
}
}
let missing = members.iter().filter(|m| !found.contains(&m.to_ascii_uppercase())).cloned().collect();
Ok((e.apply(&text)?, missing))
}
/// The board with `class` taken off every footprint (an emptied `component_classes` list goes too).
pub fn remove_class(text: &str, class: &str) -> Result<String, String> {
let root = sxedit::parse(text)?;
let mut e = Edits::new();
for fp in root.kids_tagged("footprint") {
let Some(cc) = fp.kid("component_classes") else { continue };
let ours: Vec<&Node> = cc.kids_tagged("class").filter(|k| k.atom(1) == Some(class)).collect();
if ours.is_empty() {
continue;
}
if ours.len() == cc.kids_tagged("class").count() {
e.remove_node(text, cc);
} else {
for k in ours {
e.remove_node(text, k);
}
}
}
if e.is_empty() {
return Ok(text.to_string());
}
e.apply(text)
}
/// The board without the class's room (its rule area).
pub fn remove_room(text: &str, class: &str) -> Result<String, String> {
let root = sxedit::parse(text)?;
let name = room_name(class);
let mut e = Edits::new();
for z in root.kids_tagged("zone").filter(|z| z.value_of("name") == Some(name.as_str())) {
e.remove_node(text, z);
}
if e.is_empty() {
return Ok(text.to_string());
}
e.apply(text)
}
/// The room's rule area over `rect` [x0, y0, x1, y1] (KiCad's frame) on both outer copper layers,
/// everything allowed inside it: it only marks the area the assertion rule checks.
pub fn room_zone(class: &str, rect: [f64; 4]) -> String {
let n = |v: f64| format!("{}", (v * 1e4).round() / 1e4 + 0.0);
let [x0, y0, x1, y1] = rect;
format!("\t(zone\n\t\t(layers \"F.Cu\" \"B.Cu\")\n\t\t(uuid \"{}\")\n\t\t(name {})\n\t\t(hatch edge 0.5)\n\t\t(connect_pads\n\t\t\t(clearance 0)\n\t\t)\n\t\t(min_thickness 0.25)\n\t\t(keepout\n\t\t\t(tracks allowed)\n\t\t\t(vias allowed)\n\t\t\t(pads allowed)\n\t\t\t(copperpour allowed)\n\t\t\t(footprints allowed)\n\t\t)\n\t\t(placement\n\t\t\t(enabled no)\n\t\t\t(sheetname \"\")\n\t\t)\n\t\t(fill\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t\t(island_removal_mode 0)\n\t\t)\n\t\t(polygon\n\t\t\t(pts\n\t\t\t\t(xy {} {}) (xy {} {}) (xy {} {}) (xy {} {})\n\t\t\t)\n\t\t)\n\t)",
crate::schematic::new_uuid(), sxedit::q(&room_name(class)), n(x0), n(y0), n(x1), n(y0), n(x1), n(y1), n(x0), n(y1))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> 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 a_class_is_set_listed_replaced_and_removed() {
let t = fixture();
let (t1, missing) = set_class(&t, "Power", &["R1".into(), "u1".into(), "Q9".into()]).unwrap();
assert_eq!(missing, vec!["Q9".to_string()]);
let l = list(&t1).unwrap();
assert_eq!(l["classes"][0], json!({"name": "Power", "members": ["R1", "U1"]}));
// a second class on the same footprint keeps the first
let (t2, _) = set_class(&t1, "Analog", &["R1".into()]).unwrap();
assert_eq!(list(&t2).unwrap()["classes"].as_array().unwrap().len(), 2);
// the same name replaces the members
let (t3, _) = set_class(&t2, "Power", &["C1".into()]).unwrap();
let l3 = list(&t3).unwrap();
assert!(l3["classes"].as_array().unwrap().iter().any(|c| c == &json!({"name": "Power", "members": ["C1"]})), "{l3}");
let t4 = remove_class(&remove_class(&t3, "Power").unwrap(), "Analog").unwrap();
assert_eq!(list(&t4).unwrap()["classes"], json!([]));
assert!(!t4.contains("component_classes"), "an emptied list is removed");
}
#[test]
fn a_room_is_a_named_rule_area() {
let t = fixture();
let k = t.trim_end().rfind(')').unwrap();
let with = format!("{}{}\n)\n", &t[..k], room_zone("Power", [10.0, 20.0, 30.0, 40.0]));
assert_eq!(list(&with).unwrap()["rooms"][0], json!({"class": "Power", "rect": [10.0, 20.0, 30.0, 40.0]}));
assert!(crate::pcb::parse_pcb_text(&with, "b.kicad_pcb").is_ok());
let gone = remove_room(&with, "Power").unwrap();
assert_eq!(list(&gone).unwrap()["rooms"], json!([]));
assert!(room_rule("Power").contains("A.enclosedByArea('Adom room Power')"));
}
}