master
Colby Knox Merge PR #318: kicad-lane: 1.0.71, library_views and set_layer_visibility febd39d 13d ago
//! 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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}

/// 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&lt;1&gt;") && 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");
    }
}