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
//! 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')"));
    }
}