master
Colby Knox Merge PR #343: kicad-lane: 1.0.85, simulate sweeps and notSimulated, land pattern from the engine, engine 0.19.0 7f17c07 12d ago
//! Footprints from a datasheet's land pattern, the EDA Bridge Standard's footprint_from_land_pattern.
//! The standard's engine (adom-eda-checks, footprint_from_land_pattern) lays the footprint out from the numbers the
//! datasheet prints: pads numbered the standard way, an exposed pad, the courtyard, silkscreen kept clear of the copper
//! with a pin-1 dot, and a fab outline with a pin-1 chamfer (mm, Y up, origin at the centre, pin 1 top-left), so the same
//! numbers give the same footprint in every tool. This module writes that layout in KiCad's terms: `from_engine` reads
//! the engine's reply, `fp_create_args` turns it into `fpedit::create`'s arguments in KiCad's frame (y down), and
//! `vrml_box` is the extruded 3D body.
use serde_json::{json, Value};

#[derive(Debug, Clone, PartialEq)]
pub struct Pad { pub name: String, pub x: f64, pub y: f64, pub w: f64, pub h: f64, pub shape: String, pub hole: f64 }

#[derive(Debug, Default)]
pub struct Fp { pub pads: Vec<Pad>, pub lines: Vec<(&'static str, f64, f64, f64, f64, f64)>, pub discs: Vec<(&'static str, f64, f64, f64)>, pub courtyard: (f64, f64, f64, f64) }

fn r3(v: f64) -> f64 { (v * 1000.0).round() / 1000.0 }
fn num(a: &Value, k: &str) -> Option<f64> { a.get(k).and_then(|v| v.as_f64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))) }

/// The engine's layout reply (pads, lines, discs, courtyardMm; layers overlay, fab, courtyard) as an `Fp`.
pub fn from_engine(v: &Value) -> Result<Fp, String> {
    let n = |x: &Value, k: &str| x[k].as_f64().unwrap_or(0.0);
    let layer = |l: &str| -> Result<&'static str, String> {
        match l { "overlay" => Ok("overlay"), "fab" => Ok("fab"), "courtyard" => Ok("courtyard"), o => Err(format!("the engine's layout has a layer KiCad's writer does not map: {o}")) }
    };
    let pads: Vec<Pad> = v["pads"].as_array().ok_or("the engine's layout has no pads")?.iter()
        .map(|p| Pad { name: p["name"].as_str().unwrap_or("").to_string(), x: n(p, "x"), y: n(p, "y"), w: n(p, "w"), h: n(p, "h"), shape: p["shape"].as_str().unwrap_or("roundrect").to_string(), hole: n(p, "hole") })
        .collect();
    if pads.is_empty() { return Err("no pads".into()); }
    let mut lines = Vec::new();
    for l in v["lines"].as_array().into_iter().flatten() {
        lines.push((layer(l["layer"].as_str().unwrap_or(""))?, n(l, "x1"), n(l, "y1"), n(l, "x2"), n(l, "y2"), n(l, "width")));
    }
    let mut discs = Vec::new();
    for d in v["discs"].as_array().into_iter().flatten() {
        discs.push((layer(d["layer"].as_str().unwrap_or(""))?, n(d, "x"), n(d, "y"), n(d, "r")));
    }
    let c = &v["courtyardMm"];
    let cy = |i: usize| c[i].as_f64().unwrap_or(0.0);
    Ok(Fp { pads, lines, discs, courtyard: (cy(0), cy(1), cy(2), cy(3)) })
}

/// A plain description: "SOIC-8 dual, 1.27 mm pitch, 8 pads (from the datasheet land pattern)".
pub fn describe(a: &Value, fp: &Fp) -> String {
    let fam = a.get("family").and_then(|f| f.as_str()).unwrap_or("dual");
    let pitch = num(a, "pitch").map(|p| format!(", {p} mm pitch")).unwrap_or_default();
    format!("{} {}{}, {} pads (from the datasheet land pattern)", a.get("name").and_then(|n| n.as_str()).unwrap_or(""), fam, pitch, fp.pads.len())
}

/// `fpedit::create`'s arguments for the layout: KiCad's footprint frame (y down), pads with their
/// numbers and shapes, the layout's own silkscreen, fab and courtyard as explicit graphics.
pub fn fp_create_args(a: &Value, fp: &Fp) -> Value {
    let shape = |s: &str| match s { "round" | "circle" => "circle", "oval" => "oval", "rect" => "rect", _ => "roundrect" };
    let pads: Vec<Value> = fp.pads.iter().map(|p| json!({"number": p.name, "type": "smd", "shape": shape(&p.shape), "size": [p.w, p.h], "at": [p.x, r3(-p.y)]})).collect();
    fn layer(l: &str) -> &str { if l == "overlay" { "silk" } else { l } }
    let mut graphics: Vec<Value> = fp.lines.iter().map(|(l, x0, y0, x1, y1, w)| json!({"layer": layer(l), "start": [x0, r3(-y0)], "end": [x1, r3(-y1)], "width": w})).collect();
    graphics.extend(fp.discs.iter().map(|(l, x, y, r)| json!({"layer": layer(l), "center": [x, r3(-y)], "r": r})));
    let c = fp.courtyard;
    let body = a.get("body").cloned().unwrap_or(json!({}));
    let mut out = json!({"pads": pads, "graphics": graphics, "courtyardRect": [c.0, r3(-c.3), c.2, r3(-c.1)], "attr": "smd"});
    if let (Some(w), Some(l)) = (num(&body, "w"), num(&body, "l").or_else(|| num(&body, "h"))) {
        out["bodySize"] = json!([w, l]);
    }
    out
}

/// A box the size of the body, `standoff` above the board, as a VRML 2.0 file KiCad shows (mm, in
/// KiCad's 3D units of 2.54 mm per unit; z up from the board).
pub fn vrml_box(w: f64, l: f64, height: f64, standoff: f64) -> String {
    let u = |mm: f64| mm / 2.54;
    let h = (height - standoff).max(0.01);
    format!("#VRML V2.0 utf8\n# body box from the datasheet land pattern: {w} x {l} x {height} mm\nTransform {{\n  translation 0 0 {}\n  children [\n    Shape {{\n      appearance Appearance {{ material Material {{ diffuseColor 0.15 0.15 0.15 }} }}\n      geometry Box {{ size {} {} {} }}\n    }}\n  ]\n}}\n",
        r6(u(standoff + h / 2.0)), r6(u(w)), r6(u(l)), r6(u(h)))
}

fn r6(v: f64) -> f64 {
    (v * 1e6).round() / 1e6
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A SOIC-8 as the engine lays it out (trimmed: pins 1 and 8, one silk line, the fab chamfer, the pin-1 dot).
    fn soic() -> Value {
        json!({"pads": [{"name": "1", "x": -2.7, "y": 1.905, "w": 1.55, "h": 0.6, "shape": "roundrect", "hole": 0.0},
                        {"name": "8", "x": 2.7, "y": 1.905, "w": 1.55, "h": 0.6, "shape": "roundrect", "hole": 0.0}],
               "lines": [{"layer": "overlay", "x1": -1.95, "y1": 2.56, "x2": 1.95, "y2": 2.56, "width": 0.15},
                         {"layer": "fab", "x1": -0.95, "y1": 2.45, "x2": 1.95, "y2": 2.45, "width": 0.1},
                         {"layer": "courtyard", "x1": -3.73, "y1": -2.7, "x2": 3.73, "y2": 2.7, "width": 0.05}],
               "discs": [{"layer": "overlay", "x": -3.8, "y": 1.905, "r": 0.15}],
               "courtyardMm": [-3.73, -2.7, 3.73, 2.7]})
    }

    #[test]
    fn the_engines_layout_reads_back() {
        let fp = from_engine(&soic()).unwrap();
        assert_eq!((fp.pads.len(), fp.lines.len(), fp.discs.len()), (2, 3, 1));
        assert_eq!(fp.pads[0], Pad { name: "1".into(), x: -2.7, y: 1.905, w: 1.55, h: 0.6, shape: "roundrect".into(), hole: 0.0 });
        assert_eq!(fp.courtyard, (-3.73, -2.7, 3.73, 2.7));
        let mut odd = soic();
        odd["lines"][0]["layer"] = json!("paste");
        assert!(from_engine(&odd).unwrap_err().contains("paste"));
        assert!(from_engine(&json!({"pads": []})).is_err());
    }

    #[test]
    fn the_kicad_footprint_is_y_down_with_the_layouts_own_graphics() {
        let a = json!({"family": "dual", "pins": 8, "pitch": 1.27, "span": 5.4, "padLength": 1.55, "padWidth": 0.6, "body": {"w": 3.9, "l": 4.9, "height": 1.75}});
        let fp = from_engine(&soic()).unwrap();
        let k = fp_create_args(&a, &fp);
        // pin 1 top-left: y up 1.905 is KiCad's y down -1.905
        assert_eq!(k["pads"][0], json!({"number": "1", "type": "smd", "shape": "roundrect", "size": [1.55, 0.6], "at": [-2.7, -1.905]}));
        let c = k["courtyardRect"].as_array().unwrap();
        assert!(c[1].as_f64().unwrap() < c[3].as_f64().unwrap());
        assert!(k["graphics"].as_array().unwrap().iter().any(|g| g["layer"] == "fab") && k["graphics"].as_array().unwrap().iter().any(|g| g["r"].is_number()));
        let (text, info) = crate::fpedit::create("SOIC-8_test", &k).unwrap();
        assert_eq!(info["pads"], 2);
        assert!(text.contains("(fp_line") && text.contains("F.CrtYd") && text.contains("F.Fab") && !text.contains("fp_rect"), "{text}");
        assert!(vrml_box(3.9, 4.9, 1.75, 0.1).contains("geometry Box { size 1.535433 1.929134 0.649606 }"));
    }
}