main
Caleb Add the Rust source, flow definition, assets and install scripts cf9809b
//! Just enough of a `.kicad_pcb` to compare the copied board with the photos: each footprint's
//! reference, side, position and rotation, and the copper layer count. A small S-expression scanner;
//! nothing here writes a board.

#[derive(Debug, Clone)]
pub struct Footprint { pub reference: String, pub layer: String, pub x: f64, pub y: f64, pub rot: f64 }

/// The child lists of the list that opens at `open` (byte index of its '('), as (start, end) spans.
fn children(t: &[u8], open: usize) -> Vec<(usize, usize)> {
    let mut out = vec![];
    let (mut i, mut depth, mut in_str, mut start) = (open + 1, 1usize, false, 0usize);
    while i < t.len() && depth > 0 {
        let c = t[i];
        if in_str {
            if c == b'\\' { i += 1; } else if c == b'"' { in_str = false; }
        } else if c == b'"' { in_str = true; }
        else if c == b'(' { if depth == 1 { start = i; } depth += 1; }
        else if c == b')' { depth -= 1; if depth == 1 { out.push((start, i + 1)); } }
        i += 1;
    }
    out
}

fn head(s: &str) -> &str { s[1..].split(|c: char| c.is_whitespace() || c == ')').next().unwrap_or("") }

fn quoted(s: &str, n: usize) -> Option<String> {
    let parts: Vec<&str> = s.split('"').collect();
    parts.get(2 * n + 1).map(|x| x.to_string())
}

fn nums(s: &str) -> Vec<f64> {
    s.trim_start_matches('(').trim_end_matches(')').split_whitespace().skip(1).filter_map(|x| x.parse().ok()).collect()
}

pub fn footprints(text: &str) -> Vec<Footprint> {
    let t = text.as_bytes();
    let Some(root) = text.find('(') else { return vec![] };
    let mut out = vec![];
    for (s, e) in children(t, root) {
        let fp = &text[s..e];
        if head(fp) != "footprint" && head(fp) != "module" { continue; }
        let (mut reference, mut layer, mut at) = (String::new(), String::new(), vec![]);
        for (cs, ce) in children(t, s) {
            let c = &text[cs..ce];
            match head(c) {
                "layer" if layer.is_empty() => layer = quoted(c, 0).unwrap_or_default(),
                "at" if at.is_empty() => at = nums(c),
                "property" if reference.is_empty() && quoted(c, 0).as_deref() == Some("Reference") => reference = quoted(c, 1).unwrap_or_default(),
                "fp_text" if reference.is_empty() && c.starts_with("(fp_text reference") => reference = quoted(c, 0).unwrap_or_default(),
                _ => {}
            }
        }
        out.push(Footprint { reference, layer, x: *at.first().unwrap_or(&0.0), y: *at.get(1).unwrap_or(&0.0), rot: *at.get(2).unwrap_or(&0.0) });
    }
    out
}

/// Copper layers declared in the board's `(layers ...)` section.
pub fn copper_layers(text: &str) -> usize {
    let t = text.as_bytes();
    let Some(root) = text.find('(') else { return 0 };
    for (s, _) in children(t, root) {
        if head(&text[s..]) != "layers" { continue; }
        return children(t, s).iter().filter(|(a, b)| quoted(&text[*a..*b], 0).map(|n| n.ends_with(".Cu")).unwrap_or(false)).count();
    }
    0
}

#[cfg(test)]
mod tests {
    use super::*;
    const PCB: &str = r#"(kicad_pcb (version 20250114) (generator "pcbnew")
	(layers (0 "F.Cu" signal) (4 "In1.Cu" power) (6 "In2.Cu" power) (2 "B.Cu" signal) (9 "F.Adhes" user "F.Adhesive"))
	(footprint "Resistor_SMD:R_0603" (layer "F.Cu") (uuid "a")
		(at 10.5 20.25 90)
		(property "Reference" "R1" (at 0 -1.4 90) (layer "F.SilkS"))
		(pad "1" smd rect (at -0.8 0) (size 0.9 0.9) (layers "F.Cu"))
	)
	(footprint "Package_DFN_QFN:QFN-48" (layer "B.Cu") (uuid "b")
		(at 30 40 225)
		(property "Reference" "U1 (\"x\")" (at 0 0) (layer "B.SilkS"))
	)
	(gr_text "R9" (at 1 1))
)"#;

    #[test]
    fn reads_footprints_and_layers() {
        let f = footprints(PCB);
        assert_eq!(f.len(), 2);
        assert_eq!(f[0].reference, "R1");
        assert_eq!((f[0].x, f[0].y, f[0].rot), (10.5, 20.25, 90.0));
        assert_eq!(f[1].layer, "B.Cu");
        assert_eq!(f[1].rot, 225.0);
        assert_eq!(copper_layers(PCB), 4);
    }
}