master
Colby Knox Merge PR #313: kicad-lane: 1.0.68, BOM, JLCPCB assembly and order, land-pattern footprints, designators e1c2dac 13d ago
//! 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:?}");
    }
}