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
//! A new board from a parts list and a netlist: the EDA Bridge Standard's build_board, and
//! test_board's row of library parts. The blank KiCad 10 board (`project::blank_board`), an outline
//! on Edge.Cuts (a rectangle with optional rounded corners, or a polygon), every footprint placed
//! from its library text by its own origin (`fp_text::place_library`), and every listed pad given
//! its net (KiCad 10 names nets on the pads; there is no net table). Then read back pin by pin.
//! Pure: the verbs resolve the library files and write the board.

use std::collections::BTreeMap;

use serde_json::{json, Value};

use crate::pcb::NetFormat;

/// One part to place, in KiCad's frame (y down): its library text and where its origin goes.
pub struct Placed {
    pub reference: String,
    pub lib_id: String,
    pub text: String,
    pub x: f64,
    pub y: f64,
    pub rotation: f64,
    pub back: bool,
    pub value: Option<String>,
}

fn n(v: f64) -> String {
    let r = (v * 1e6).round() / 1e6 + 0.0;
    format!("{r}")
}

fn edge_line(a: (f64, f64), b: (f64, f64)) -> String {
    format!("\t(gr_line (start {} {}) (end {} {}) (stroke (width 0.1) (type default)) (layer \"Edge.Cuts\") (uuid \"{}\"))", n(a.0), n(a.1), n(b.0), n(b.1), crate::schematic::new_uuid())
}

fn edge_arc(a: (f64, f64), m: (f64, f64), b: (f64, f64)) -> String {
    format!("\t(gr_arc (start {} {}) (mid {} {}) (end {} {}) (stroke (width 0.1) (type default)) (layer \"Edge.Cuts\") (uuid \"{}\"))", n(a.0), n(a.1), n(m.0), n(m.1), n(b.0), n(b.1), crate::schematic::new_uuid())
}

/// The outline as Edge.Cuts items: `{rect: [x0, y0, w, h], cornerRadius?}` or `{points: [[x, y], ...]}`,
/// in the standard's frame (y up) when `y_up`, else KiCad's.
pub fn outline_items(outline: &Value, y_up: bool) -> Result<(Vec<String>, [f64; 4]), String> {
    let fy = |y: f64| if y_up { -y } else { y };
    let num = |v: &Value| v.as_f64().ok_or("outline numbers must be numbers (mm)");
    if let Some(r) = outline.get("rect").and_then(Value::as_array) {
        if r.len() != 4 {
            return Err("outline.rect is [x0, y0, width, height] in mm".into());
        }
        let (x0, y0, w, h) = (num(&r[0])?, num(&r[1])?, num(&r[2])?, num(&r[3])?);
        if w <= 0.0 || h <= 0.0 {
            return Err("the outline's width and height must be positive".into());
        }
        let (ya, yb) = (fy(y0), fy(y0 + h));
        let (top, bot) = (ya.min(yb), ya.max(yb));
        let (l, rt) = (x0, x0 + w);
        let rad = outline.get("cornerRadius").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, w.min(h) / 2.0);
        let mut items = Vec::new();
        if rad <= 0.0 {
            for (a, b) in [((l, top), (rt, top)), ((rt, top), (rt, bot)), ((rt, bot), (l, bot)), ((l, bot), (l, top))] {
                items.push(edge_line(a, b));
            }
        } else {
            let k = rad * (1.0 - std::f64::consts::FRAC_1_SQRT_2);
            items.push(edge_line((l + rad, top), (rt - rad, top)));
            items.push(edge_arc((rt - rad, top), (rt - k, top + k), (rt, top + rad)));
            items.push(edge_line((rt, top + rad), (rt, bot - rad)));
            items.push(edge_arc((rt, bot - rad), (rt - k, bot - k), (rt - rad, bot)));
            items.push(edge_line((rt - rad, bot), (l + rad, bot)));
            items.push(edge_arc((l + rad, bot), (l + k, bot - k), (l, bot - rad)));
            items.push(edge_line((l, bot - rad), (l, top + rad)));
            items.push(edge_arc((l, top + rad), (l + k, top + k), (l + rad, top)));
        }
        return Ok((items, [l, top, rt, bot]));
    }
    if let Some(p) = outline.get("points").and_then(Value::as_array) {
        let pts: Vec<(f64, f64)> = p.iter().map(|q| Ok::<_, String>((num(&q[0])?, fy(num(&q[1])?)))).collect::<Result<_, _>>()?;
        if pts.len() < 3 {
            return Err("outline.points needs 3 points or more".into());
        }
        let items = (0..pts.len()).map(|i| edge_line(pts[i], pts[(i + 1) % pts.len()])).collect();
        let (x0, x1) = pts.iter().fold((f64::MAX, f64::MIN), |m, q| (m.0.min(q.0), m.1.max(q.0)));
        let (y0, y1) = pts.iter().fold((f64::MAX, f64::MIN), |m, q| (m.0.min(q.1), m.1.max(q.1)));
        return Ok((items, [x0, y0, x1, y1]));
    }
    Err("outline is {rect: [x0, y0, w, h], cornerRadius?} or {points: [[x, y], ...]} (mm)".into())
}

/// The board text: `board` (a blank board or one to append to), the outline items, the parts placed
/// and the nets set on their pads (`nets`: net -> ["R1.1", ...]). Returns the text and a report of
/// what was placed and wired, and the pins that could not be (no such part on the build, no such pad).
pub fn build(board: &str, outline: &[String], parts: &[Placed], nets: &BTreeMap<String, Vec<String>>) -> Result<(String, Value), String> {
    let copper = crate::fp_text::copper_count(&crate::sxedit::parse(board)?);
    let mut blocks: BTreeMap<String, String> = BTreeMap::new();
    for p in parts {
        let block = crate::fp_text::place_library(&p.text, &p.lib_id, p.x, p.y, p.rotation, p.back, &p.reference, p.value.as_deref(), copper)
            .map_err(|e| format!("{}: {e}", p.reference))?;
        blocks.insert(p.reference.clone(), block);
    }
    let empty = BTreeMap::new();
    let mut wired = 0;
    let mut wrong = Vec::new();
    for (net, pins) in nets {
        for pin in pins {
            let Some((r, pad)) = pin.rsplit_once('.') else {
                wrong.push(json!({"pin": pin, "want": net, "got": "not Ref.Pad"}));
                continue;
            };
            let Some(block) = blocks.get_mut(r) else {
                wrong.push(json!({"pin": pin, "want": net, "got": "no such part on the board"}));
                continue;
            };
            if !block.contains(&format!("(pad \"{pad}\" ")) {
                wrong.push(json!({"pin": pin, "want": net, "got": "the footprint has no such pad"}));
                continue;
            }
            *block = crate::pcb_sync::set_pad_net(block, pad, Some(net), NetFormat::Name, &empty);
            wired += 1;
        }
    }
    let mut items: Vec<String> = outline.to_vec();
    items.extend(blocks.into_values());
    let text = crate::pcb::splice(board, &items);
    // read back: every requested pin carries its net
    let parsed = crate::pcb::parse_pcb_text(&text, "board.kicad_pcb")?;
    let pads = crate::pcb::pads(&parsed);
    for (net, pins) in nets {
        for pin in pins {
            let Some((r, pad)) = pin.rsplit_once('.') else { continue };
            if let Some(p) = pads.iter().find(|q| q.reference == r && q.pad == pad) {
                if p.net_name != *net {
                    wrong.push(json!({"pin": pin, "want": net, "got": p.net_name}));
                }
            }
        }
    }
    wrong.dedup();
    Ok((text, json!({"placed": parts.len(), "padsWired": wired, "wrongPins": wrong})))
}

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

    const R0603: &str = "(footprint \"R_0603\" (layer \"F.Cu\") (property \"Reference\" \"REF**\" (at 0 -1.4 0) (layer \"F.SilkS\") (uuid \"a\") (effects (font (size 1 1) (thickness 0.15))))
        (property \"Value\" \"R\" (at 0 1.4 0) (layer \"F.Fab\") (uuid \"b\") (effects (font (size 1 1) (thickness 0.15))))
        (pad \"1\" smd roundrect (at -0.8 0) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"c\"))
        (pad \"2\" smd roundrect (at 0.8 0) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"d\")))";

    #[test]
    fn a_board_from_parts_and_nets_reads_back_pin_by_pin() {
        let blank = crate::project::blank_board(None, 2).unwrap();
        let (outline, bb) = outline_items(&json!({"rect": [0, 0, 20, 10], "cornerRadius": 1}), true).unwrap();
        assert_eq!(bb, [0.0, -10.0, 20.0, 0.0]);
        assert_eq!(outline.len(), 8, "four sides and four rounded corners");
        let parts = vec![
            Placed { reference: "R1".into(), lib_id: "Lib:R_0603".into(), text: R0603.into(), x: 5.0, y: -5.0, rotation: 0.0, back: false, value: Some("10k".into()) },
            Placed { reference: "R2".into(), lib_id: "Lib:R_0603".into(), text: R0603.into(), x: 15.0, y: -5.0, rotation: 90.0, back: false, value: None },
        ];
        let mut nets = BTreeMap::new();
        nets.insert("VIN".to_string(), vec!["R1.1".to_string()]);
        nets.insert("MID".to_string(), vec!["R1.2".to_string(), "R2.1".to_string()]);
        nets.insert("GND".to_string(), vec!["R2.2".to_string(), "R3.1".to_string(), "R2.9".to_string()]);
        let (text, rep) = build(&blank, &outline, &parts, &nets).unwrap();
        assert_eq!(rep["placed"], 2);
        assert_eq!(rep["padsWired"], 4);
        let wrong = rep["wrongPins"].as_array().unwrap();
        assert_eq!(wrong.len(), 2, "{rep}");
        assert!(wrong.iter().any(|w| w["pin"] == "R3.1") && wrong.iter().any(|w| w["pin"] == "R2.9"));
        let parsed = crate::pcb::parse_pcb_text(&text, "b.kicad_pcb").unwrap();
        let pads = crate::pcb::pads(&parsed);
        assert_eq!(pads.iter().find(|p| p.reference == "R2" && p.pad == "1").unwrap().net_name, "MID");
        assert!(text.contains("(property \"Value\" \"10k\"") && text.contains("gr_arc"));
    }
}