master
Colby Knox plate_vias, cleanup_libs, sch_update_from_library plan (standard#29) 6343078 12d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078
//! Library authoring on EAGLE .lbr text, offline: the standard's create_footprint, create_symbol,
//! read_symbol, find_symbol, copy/rename/remove/xcopy_footprint, clone/rename/remove_symbol and
//! set_params. In EAGLE a footprint is a <package>; the standard's "symbol" (a component you place)
//! is a <deviceset> with the <symbol>s its gates use. A symbol made here keeps its pin numbers on a
//! package-less device (attribute ADOM_PINMAP, "number:pin;..."), the way EAGLE's own supply
//! symbols carry no package, so set_params {footprint} can join pins to pads later.

use crate::Element;
use serde_json::{json, Value};

const TEMPLATE_HEAD: &str = include_str!("lbr_template_head.xml");
const MIL: f64 = 0.0254;

/// A new, empty library document (the layer table of a Fusion library).
pub fn new_library(description: &str) -> String {
    format!("{TEMPLATE_HEAD}<library>\n<description>{}</description>\n<packages>\n</packages>\n<symbols>\n</symbols>\n<devicesets>\n</devicesets>\n</library>\n</drawing>\n</eagle>\n",
            description.replace('&', "&amp;").replace('<', "&lt;"))
}

pub(crate) fn f(v: f64) -> String { let s = format!("{:.4}", v); let s = s.trim_end_matches('0').trim_end_matches('.').to_string(); if s == "-0" { "0".into() } else { s } }
pub(crate) fn el(tag: &str, attrs: &[(&str, String)]) -> Element { let mut e = Element::new(tag); e.attrs = attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(); e }
pub(crate) fn set(e: &mut Element, k: &str, v: &str) { match e.attrs.iter_mut().find(|(kk, _)| kk == k) { Some(a) => a.1 = v.to_string(), None => e.attrs.push((k.into(), v.into())) } }
pub(crate) fn lib_mut(root: &mut Element) -> Result<&mut Element, String> { root.find_deep_mut("library").ok_or_else(|| "not a library: no <library>".to_string()) }
pub(crate) fn section<'a>(lib: &'a mut Element, tag: &str) -> &'a mut Element {
    if lib.find_child(tag).is_none() {
        // EAGLE order inside <library>: description?, packages, packages3d?, symbols, devicesets
        let order = ["description", "packages", "packages3d", "symbols", "devicesets"];
        let rank = |t: &str| order.iter().position(|o| *o == t).unwrap_or(9);
        let at = lib.children.iter().position(|c| rank(&c.tag) > rank(tag)).unwrap_or(lib.children.len());
        lib.children.insert(at, Element::new(tag));
    }
    lib.find_child_mut(tag).unwrap()
}
pub(crate) fn names(sec: &Element, tag: &str) -> Vec<String> { sec.children.iter().filter(|c| c.tag == tag).filter_map(|c| c.attr("name").map(str::to_string)).collect() }
pub(crate) fn find_ci<'a>(list: &'a [String], want: &str) -> Option<&'a String> { list.iter().find(|n| *n == want).or_else(|| list.iter().find(|n| n.eq_ignore_ascii_case(want))) }

/// Pads as the standard writes them: "des,x_mil,y_mil,w_mil,h_mil,rect|round,hole_mil;..." (hole 0 = SMD),
/// or a JSON list of {number, at:[x,y] mm, sizeMm:[w,h], shape, drillMm}.
fn parse_pads(v: &Value) -> Result<Vec<Element>, String> {
    let mut out = Vec::new();
    let mk = |des: &str, x: f64, y: f64, w: f64, h: f64, round: bool, hole: f64| -> Element {
        if hole > 0.0 {
            let mut e = el("pad", &[("name", des.into()), ("x", f(x)), ("y", f(y)), ("drill", f(hole)), ("diameter", f(w.max(h)))]);
            if !round { e.attrs.push(("shape".into(), "square".into())); }
            if (w - h).abs() > 1e-6 { e.attrs.retain(|a| a.0 != "shape"); e.attrs.push(("shape".into(), "long".into())); if h > w { e.attrs.push(("rot".into(), "R90".into())); } set(&mut e, "diameter", &f(w.min(h))); }
            e
        } else {
            let mut e = el("smd", &[("name", des.into()), ("x", f(x)), ("y", f(y)), ("dx", f(w)), ("dy", f(h)), ("layer", "1".into())]);
            if round { e.attrs.push(("roundness".into(), "100".into())); }
            e
        }
    };
    if let Some(s) = v.as_str() {
        for (i, item) in s.split(';').map(str::trim).filter(|t| !t.is_empty()).enumerate() {
            let p: Vec<&str> = item.split(',').map(str::trim).collect();
            if p.len() < 6 { return Err(format!("pad {}: want des,x_mil,y_mil,w_mil,h_mil,rect|round[,hole_mil], got {item}", i + 1)); }
            let n = |k: usize| p.get(k).and_then(|t| t.parse::<f64>().ok()).unwrap_or(0.0);
            out.push(mk(p[0], n(1) * MIL, n(2) * MIL, n(3) * MIL, n(4) * MIL, p[5].eq_ignore_ascii_case("round"), n(6) * MIL));
        }
    } else if let Some(a) = v.as_array() {
        for (i, p) in a.iter().enumerate() {
            let des = p["number"].as_str().map(str::to_string).or(p["number"].as_u64().map(|n| n.to_string())).ok_or(format!("pad {}: number", i + 1))?;
            let at = p["at"].as_array().ok_or(format!("pad {des}: at [x, y] mm"))?;
            let sz = p["sizeMm"].as_array().ok_or(format!("pad {des}: sizeMm [w, h]"))?;
            let g = |a: &Vec<Value>, k: usize| a.get(k).and_then(|x| x.as_f64()).unwrap_or(0.0);
            out.push(mk(&des, g(at, 0), g(at, 1), g(sz, 0), g(sz, 1), p["shape"].as_str() == Some("round"), p["drillMm"].as_f64().unwrap_or(0.0)));
        }
    } else { return Err("pads is required".into()); }
    if out.is_empty() { return Err("pads is empty".into()); }
    let mut seen = std::collections::HashSet::new();
    for e in &out { let n = e.attr("name").unwrap_or(""); if !seen.insert(n.to_string()) { return Err(format!("two pads are numbered {n}")); } }
    Ok(out)
}

/// The standard's create_footprint: a package from a pad list, with a documentation outline round
/// the pads and >NAME / >VALUE placeholders. Several at once with `footprints`.
pub fn create_footprint(xml: Option<&str>, args: &Value) -> Result<(String, Value), String> {
    let text = xml.map(str::to_string).unwrap_or_else(|| new_library("made by fusion-bridge"));
    let mut root = Element::parse(&text).map_err(|e| e.to_string())?;
    let specs: Vec<Value> = match args["footprints"].as_array() { Some(a) => a.iter().map(|x| { let mut m = args.clone(); for (k, v) in x.as_object().cloned().unwrap_or_default() { m[k] = v; } m }).collect(), None => vec![args.clone()] };
    let mut made = Vec::new();
    {
        let lib = lib_mut(&mut root)?;
        for spec in &specs {
            let name = spec["name"].as_str().filter(|s| !s.is_empty()).ok_or("name is required")?.to_string();
            let pads = parse_pads(&spec["pads"])?;
            let sec = section(lib, "packages");
            if find_ci(&names(sec, "package"), &name).is_some() { return Err(format!("footprint {name} already exists (rename_footprint, remove_footprint, or another name)")); }
            let mut pkg = el("package", &[("name", name.clone())]);
            if let Some(d) = spec["description"].as_str() { let mut de = Element::new("description"); de.text = d.into(); pkg.children.push(de); }
            let ext = |e: &Element| -> (f64, f64, f64, f64) {
                let g = |k: &str| e.attr(k).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
                let (x, y) = (g("x"), g("y"));
                let (hw, hh) = if e.tag == "smd" { (g("dx") / 2.0, g("dy") / 2.0) } else { let d = g("diameter").max(g("drill") * 1.6) / 2.0; (d, d) };
                (x - hw, y - hh, x + hw, y + hh)
            };
            let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
            for p in &pads { let (a, b, c, d) = ext(p); x0 = x0.min(a); y0 = y0.min(b); x1 = x1.max(c); y1 = y1.max(d); }
            let n = pads.len();
            pkg.children.extend(pads);
            let m = 0.25;
            for (a, b) in [((x0 - m, y0 - m), (x1 + m, y0 - m)), ((x1 + m, y0 - m), (x1 + m, y1 + m)), ((x1 + m, y1 + m), (x0 - m, y1 + m)), ((x0 - m, y1 + m), (x0 - m, y0 - m))] {
                pkg.children.push(el("wire", &[("x1", f(a.0)), ("y1", f(a.1)), ("x2", f(b.0)), ("y2", f(b.1)), ("width", "0.1".into()), ("layer", "51".into())]));
            }
            let mut tn = el("text", &[("x", f(x0 - m)), ("y", f(y1 + m + 0.3)), ("size", "1".into()), ("layer", "25".into())]); tn.text = ">NAME".into();
            let mut tv = el("text", &[("x", f(x0 - m)), ("y", f(y0 - m - 1.3)), ("size", "1".into()), ("layer", "27".into())]); tv.text = ">VALUE".into();
            pkg.children.push(tn); pkg.children.push(tv);
            section(lib, "packages").children.push(pkg);
            made.push(json!({"footprint": name, "pads": n}));
        }
    }
    let state = if xml.is_some() { "opened" } else { "created" };
    Ok((root.to_xml_document(), json!({"footprints": made, "libraryState": state})))
}

/// A package from a laid-out land pattern (the shape the standard's footprint_from_land_pattern
/// returns): pads {name, x, y, w, h, shape, hole} in mm, Y up; lines {layer: overlay|fab|courtyard,
/// x1, y1, x2, y2, width}; discs {layer, x, y, r}. Silk goes on tPlace (21), fab on tDocu (51), the
/// courtyard on tKeepout (39), which is where read_footprint looks for it.
pub fn create_from_layout(xml: Option<&str>, name: &str, description: &str, layout: &Value) -> Result<(String, Value), String> {
    let text = xml.map(str::to_string).unwrap_or_else(|| new_library("made by fusion-bridge"));
    let mut root = Element::parse(&text).map_err(|e| e.to_string())?;
    let layer = |l: &str| -> Result<&'static str, String> { match l { "overlay" | "silk" => Ok("21"), "fab" | "balls" => Ok("51"), "courtyard" => Ok("39"), "comment" | "document" => Ok("48"), "copper" | "top" => Ok("1"), o => Err(format!("unknown layer {o}")) } };
    let g = |v: &Value, k: &str| v[k].as_f64().unwrap_or(0.0);
    let pads = layout["pads"].as_array().filter(|a| !a.is_empty()).ok_or("the layout has no pads")?;
    let mut pkg = el("package", &[("name", name.to_string())]);
    if !description.is_empty() { let mut de = Element::new("description"); de.text = description.into(); pkg.children.push(de); }
    let mut seen: std::collections::BTreeMap<String, Vec<String>> = Default::default();
    for p in pads {
        let mut n = p["name"].as_str().map(str::to_string).or(p["name"].as_u64().map(|n| n.to_string())).ok_or("a pad has no name")?;
        // EAGLE pad names are unique; a spec that repeats one (the heatsink-via convention) gets
        // N@2, N@3 ..., which a device connects to the same pin
        let seen_n = seen.entry(n.clone()).or_default();
        if !seen_n.is_empty() {
            if layout["repeatedPads"].as_bool() != Some(true) { return Err(format!("two pads are numbered {n}")); }
            n = format!("{n}@{}", seen_n.len() + 1);
        }
        seen_n.push(n.clone());
        let (x, y, w, h, hole) = (g(p, "x"), g(p, "y"), g(p, "w"), g(p, "h"), g(p, "hole"));
        let shape = p["shape"].as_str().unwrap_or("roundrect");
        let e = if hole > 0.0 {
            let mut e = el("pad", &[("name", n), ("x", f(x)), ("y", f(y)), ("drill", f(hole)), ("diameter", f(w.min(h)))]);
            if (w - h).abs() > 1e-6 { e.attrs.push(("shape".into(), "long".into())); if h > w { e.attrs.push(("rot".into(), "R90".into())); } }
            else if shape != "round" { e.attrs.push(("shape".into(), "square".into())); }
            e
        } else {
            let mut e = el("smd", &[("name", n), ("x", f(x)), ("y", f(y)), ("dx", f(w)), ("dy", f(h)), ("layer", "1".into())]);
            // the engine's cornerRatio (0.25 for roundrect) is EAGLE's roundness in percent
            let round = match (shape, p["cornerRatio"].as_f64()) { ("round" | "oval", _) => 100, (_, Some(c)) if c > 0.0 => (c * 100.0).round() as i64, ("roundrect", _) => 25, _ => 0 };
            if round > 0 { e.attrs.push(("roundness".into(), round.to_string())); }
            e
        };
        pkg.children.push(e);
    }
    let mut per_layer: std::collections::BTreeMap<&str, usize> = Default::default();
    for l in layout["lines"].as_array().into_iter().flatten() {
        let ly = layer(l["layer"].as_str().unwrap_or(""))?;
        *per_layer.entry(ly).or_default() += 1;
        pkg.children.push(el("wire", &[("x1", f(g(l, "x1"))), ("y1", f(g(l, "y1"))), ("x2", f(g(l, "x2"))), ("y2", f(g(l, "y2"))), ("width", f(g(l, "width").max(0.01))), ("layer", ly.into())]));
    }
    for a in layout["arcs"].as_array().into_iter().flatten() {
        let ly = layer(a["layer"].as_str().unwrap_or(""))?;
        *per_layer.entry(ly).or_default() += 1;
        let (cx, cy, r, w, a0, a1) = (g(a, "cx"), g(a, "cy"), g(a, "r"), g(a, "width").max(0.01), g(a, "a0"), g(a, "a1"));
        let sweep = a1 - a0;
        if sweep.abs() >= 359.999 { pkg.children.push(el("circle", &[("x", f(cx)), ("y", f(cy)), ("radius", f(r)), ("width", f(w)), ("layer", ly.into())])); continue; }
        let pt = |deg: f64| (cx + r * deg.to_radians().cos(), cy + r * deg.to_radians().sin());
        let (p0, p1) = (pt(a0), pt(a1));
        pkg.children.push(el("wire", &[("x1", f(p0.0)), ("y1", f(p0.1)), ("x2", f(p1.0)), ("y2", f(p1.1)), ("width", f(w)), ("layer", ly.into()), ("curve", f(sweep))]));
    }
    for d in layout["discs"].as_array().into_iter().flatten() {
        let ly = layer(d["layer"].as_str().unwrap_or(""))?;
        // a filled dot: EAGLE draws a circle of width 0 filled
        pkg.children.push(el("circle", &[("x", f(g(d, "x"))), ("y", f(g(d, "y"))), ("radius", f(g(d, "r"))), ("width", "0".into()), ("layer", ly.into())]));
    }
    // >NAME above and >VALUE below whatever the package covers
    let (mut y0, mut y1, mut x0) = (f64::MAX, f64::MIN, f64::MAX);
    for p in pads { y0 = y0.min(g(p, "y") - g(p, "h") / 2.0); y1 = y1.max(g(p, "y") + g(p, "h") / 2.0); x0 = x0.min(g(p, "x") - g(p, "w") / 2.0); }
    for l in layout["lines"].as_array().into_iter().flatten() { for k in ["1", "2"] { y0 = y0.min(g(l, &format!("y{k}"))); y1 = y1.max(g(l, &format!("y{k}"))); x0 = x0.min(g(l, &format!("x{k}"))); } }
    let mut tn = el("text", &[("x", f(x0)), ("y", f(y1 + 0.3)), ("size", "1".into()), ("layer", "25".into())]); tn.text = ">NAME".into();
    let mut tv = el("text", &[("x", f(x0)), ("y", f(y0 - 1.3)), ("size", "1".into()), ("layer", "27".into())]); tv.text = ">VALUE".into();
    pkg.children.push(tn); pkg.children.push(tv);
    {
        let lib = lib_mut(&mut root)?;
        let sec = section(lib, "packages");
        if find_ci(&names(sec, "package"), name).is_some() { return Err(format!("footprint {name} already exists (rename_footprint, remove_footprint, or another name)")); }
        sec.children.push(pkg);
    }
    let lines = |k: &str| per_layer.get(k).copied().unwrap_or(0);
    let aliases: std::collections::BTreeMap<String, Vec<String>> = seen.into_iter().filter(|(_, v)| v.len() > 1).collect();
    Ok((root.to_xml_document(), json!({"footprint": name, "pads": pads.len(), "silkLines": lines("21"), "fabLines": lines("51"), "courtyardLines": lines("39"), "padAliases": aliases,
        "libraryState": if xml.is_some() { "opened" } else { "created" }})))
}

fn direction_for(name: &str, hidden: bool) -> &'static str {
    let u = name.to_uppercase();
    if hidden || ["GND", "VCC", "VDD", "VSS", "VEE", "V+", "V-", "AGND", "DGND", "PGND", "VIN", "VBAT", "3V3", "5V"].iter().any(|p| u == *p || u.starts_with(&format!("{p}@"))) { "pwr" } else { "pas" }
}

/// The standard's create_symbol: a component (deviceset) with one gate per part, each a
/// rectangular symbol with its pins on the sides asked; part 0 pins and hidden-net pins go on a
/// power gate placed on request. Pin numbers are kept for set_params {footprint}.
pub fn create_symbol(xml: Option<&str>, args: &Value) -> Result<(String, Value), String> {
    let text = xml.map(str::to_string).unwrap_or_else(|| new_library("made by fusion-bridge"));
    let mut root = Element::parse(&text).map_err(|e| e.to_string())?;
    let specs: Vec<Value> = match args["symbols"].as_array() { Some(a) => a.iter().map(|x| { let mut m = args.clone(); for (k, v) in x.as_object().cloned().unwrap_or_default() { m[k] = v; } m }).collect(), None => vec![args.clone()] };
    let mut made = Vec::new();
    {
        let lib = lib_mut(&mut root)?;
        for spec in &specs {
            let name = spec["name"].as_str().filter(|s| !s.is_empty()).ok_or("name is required")?.to_string();
            if find_ci(&names(section(lib, "devicesets"), "deviceset"), &name).is_some() { return Err(format!("symbol {name} already exists")); }
            let pins_s = spec["pins"].as_str().ok_or("pins is required: 'des,name,left|right|top|bottom,offset_mil[,part[,hiddenNet]];...'")?;
            let bw = spec["bodyWidthMil"].as_f64().unwrap_or(800.0) * MIL;
            let bh = spec["bodyHeightMil"].as_f64().unwrap_or(600.0) * MIL;
            // part -> [(des, name, side, offset_mm, hidden)]
            let mut parts: std::collections::BTreeMap<i64, Vec<(String, String, String, f64, bool)>> = Default::default();
            for (i, item) in pins_s.split(';').map(str::trim).filter(|t| !t.is_empty()).enumerate() {
                let p: Vec<&str> = item.split(',').map(str::trim).collect();
                if p.len() < 4 { return Err(format!("pin {}: want des,name,side,offset_mil, got {item}", i + 1)); }
                let side = p[2].to_lowercase();
                if !["left", "right", "top", "bottom"].contains(&side.as_str()) { return Err(format!("pin {}: side is left, right, top or bottom", p[0])); }
                let part: i64 = p.get(4).and_then(|v| v.parse().ok()).unwrap_or(1);
                let hidden = p.get(5).map(|v| !v.is_empty()).unwrap_or(false);
                let pname = if hidden { p[5].to_string() } else { p[1].to_string() };
                let key = if part == 0 || hidden { 0 } else { part };
                parts.entry(key).or_default().push((p[0].to_string(), pname, side, p[3].parse::<f64>().unwrap_or(0.0) * MIL, hidden));
            }
            if parts.is_empty() { return Err("pins is empty".into()); }
            let real: Vec<i64> = parts.keys().cloned().filter(|k| *k != 0).collect();
            let multi = real.len() > 1;
            let mut gates = Element::new("gates");
            let mut pinmap: Vec<String> = Vec::new();
            let mut sym_count = 0;
            for (k, pins) in &parts {
                let suffix = if *k == 0 { "_PWR".to_string() } else if multi { format!("_{}", (b'A' + (*k as u8 - 1)) as char) } else { String::new() };
                let sname = format!("{name}{suffix}");
                let mut sym = el("symbol", &[("name", sname.clone())]);
                let (hw, hh) = (bw / 2.0, bh / 2.0);
                for (a, b) in [((-hw, -hh), (hw, -hh)), ((hw, -hh), (hw, hh)), ((hw, hh), (-hw, hh)), ((-hw, hh), (-hw, -hh))] {
                    sym.children.push(el("wire", &[("x1", f(a.0)), ("y1", f(a.1)), ("x2", f(b.0)), ("y2", f(b.1)), ("width", "0.254".into()), ("layer", "94".into())]));
                }
                let mut tn = el("text", &[("x", f(-hw)), ("y", f(hh + 0.8)), ("size", "1.778".into()), ("layer", "95".into())]); tn.text = ">NAME".into();
                let mut tv = el("text", &[("x", f(-hw)), ("y", f(-hh - 2.5)), ("size", "1.778".into()), ("layer", "96".into())]); tv.text = ">VALUE".into();
                sym.children.push(tn); sym.children.push(tv);
                let mut used: std::collections::HashMap<String, usize> = Default::default();
                let len = 5.08;
                for (des, pname, side, off, hidden) in pins {
                    let c = used.entry(pname.to_uppercase()).or_insert(0); *c += 1;
                    let unique = if *c > 1 { format!("{pname}@{c}") } else { pname.clone() };
                    let (x, y, rot) = match side.as_str() {
                        "left" => (-hw - len, hh - off, "R0"), "right" => (hw + len, hh - off, "R180"),
                        "top" => (-hw + off, hh + len, "R270"), _ => (-hw + off, -hh - len, "R90"),
                    };
                    let mut pe = el("pin", &[("name", unique.clone()), ("x", f(x)), ("y", f(y)), ("length", "middle".into()), ("direction", direction_for(&unique, *hidden).into())]);
                    if rot != "R0" { pe.attrs.push(("rot".into(), rot.into())); }
                    if *hidden { pe.attrs.push(("visible".into(), "off".into())); }
                    sym.children.push(pe);
                    let gname = if *k == 0 { "P".to_string() } else if multi { ((b'A' + (*k as u8 - 1)) as char).to_string() } else { "G$1".into() };
                    pinmap.push(format!("{des}:{gname}.{unique}"));
                }
                section(lib, "symbols").children.push(sym); sym_count += 1;
                let gname = if *k == 0 { "P".to_string() } else if multi { ((b'A' + (*k as u8 - 1)) as char).to_string() } else { "G$1".into() };
                let mut g = el("gate", &[("name", gname), ("symbol", sname), ("x", f((gates.children.len() as f64) * (bw + 15.24))), ("y", "0".into())]);
                if *k == 0 { g.attrs.push(("addlevel".into(), "request".into())); }
                gates.children.push(g);
            }
            let mut ds = el("deviceset", &[("name", name.clone()), ("prefix", spec["designator"].as_str().unwrap_or("U").to_string())]);
            if let Some(d) = spec["description"].as_str() { let mut de = Element::new("description"); de.text = d.into(); ds.children.push(de); }
            ds.children.push(gates);
            let mut devs = Element::new("devices");
            let mut dev = el("device", &[("name", "".into())]);
            let mut techs = Element::new("technologies");
            let mut tech = el("technology", &[("name", "".into())]);
            tech.children.push(el("attribute", &[("name", "ADOM_PINMAP".into()), ("value", pinmap.join(";")), ("constant", "no".into())]));
            techs.children.push(tech); dev.children.push(techs); devs.children.push(dev); ds.children.push(devs);
            section(lib, "devicesets").children.push(ds);
            made.push(json!({"symbol": name, "pins": pinmap.len(), "parts": real.len().max(1), "symbols": sym_count}));
        }
    }
    let state = if xml.is_some() { "opened" } else { "created" };
    Ok((root.to_xml_document(), json!({"symbols": made, "libraryState": state})))
}

fn pin_number_map(ds: &Element) -> Vec<(String, String, String)> {
    // (number, gate, pin) from a device's connects, else from ADOM_PINMAP
    let mut out = Vec::new();
    if let Some(devs) = ds.find_child("devices") {
        for d in devs.children.iter().filter(|c| c.tag == "device") {
            if let Some(cs) = d.find_child("connects") {
                for c in cs.children.iter().filter(|c| c.tag == "connect") {
                    for pad in c.attr("pad").unwrap_or("").split(' ').filter(|p| !p.is_empty()) { out.push((pad.to_string(), c.attr("gate").unwrap_or("").to_string(), c.attr("pin").unwrap_or("").to_string())); }
                }
                if !out.is_empty() { return out; }
            }
        }
        for d in devs.children.iter().filter(|c| c.tag == "device") {
            fn walk(e: &Element, out: &mut Vec<(String, String, String)>) {
                if e.tag == "attribute" && e.attr("name") == Some("ADOM_PINMAP") {
                    for item in e.attr("value").unwrap_or("").split(';').filter(|s| !s.is_empty()) {
                        if let Some((num, gp)) = item.split_once(':') { if let Some((g, p)) = gp.split_once('.') { out.push((num.into(), g.into(), p.into())); } }
                    }
                }
                for c in &e.children { walk(c, out); }
            }
            walk(d, &mut out);
            if !out.is_empty() { return out; }
        }
    }
    out
}

fn electrical(dir: &str) -> u8 { match dir { "in" => 0, "io" => 1, "out" => 2, "oc" => 3, "hiz" => 5, "pwr" | "sup" => 7, _ => 4 } }

/// The standard's read_symbol for a deviceset.
pub fn read_symbol(xml: &str, name: &str) -> Result<Value, String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let lib = lib_mut(&mut root)?;
    let dss = section(lib, "devicesets").children.clone();
    let ds = dss.iter().find(|d| d.tag == "deviceset" && d.attr("name") == Some(name)).or_else(|| dss.iter().find(|d| d.tag == "deviceset" && d.attr("name").map(|n| n.eq_ignore_ascii_case(name)).unwrap_or(false)))
        .ok_or_else(|| format!("no symbol {name}; the library has {}", dss.iter().filter_map(|d| d.attr("name")).collect::<Vec<_>>().join(", ")))?;
    let syms = section(lib, "symbols").children.clone();
    let map = pin_number_map(ds);
    let mut pins = Vec::new();
    let gates: Vec<&Element> = ds.find_child("gates").map(|g| g.children.iter().filter(|c| c.tag == "gate").collect()).unwrap_or_default();
    for g in &gates {
        if let Some(sym) = syms.iter().find(|s| s.attr("name") == g.attr("symbol")) {
            for p in sym.children.iter().filter(|c| c.tag == "pin") {
                let pname = p.attr("name").unwrap_or("");
                let des: Vec<&str> = map.iter().filter(|(_, gg, pp)| gg == g.attr("name").unwrap_or("") && pp == pname).map(|(n, _, _)| n.as_str()).collect();
                pins.push(json!({"des": des.join(" "), "name": pname.split('@').next().unwrap_or(pname), "pin": pname, "gate": g.attr("name"), "electrical": electrical(p.attr("direction").unwrap_or("io")), "hidden": p.attr("visible") == Some("off")}));
            }
        }
    }
    let mut params = Vec::new();
    let mut models = Vec::new();
    if let Some(devs) = ds.find_child("devices") {
        for d in devs.children.iter().filter(|c| c.tag == "device") {
            if let Some(pk) = d.attr("package").filter(|p| !p.is_empty()) { models.push(json!({"type": "PCBLIB", "name": pk, "device": d.attr("name"), "isCurrent": models.is_empty()})); }
            fn walk(e: &Element, out: &mut Vec<Value>) { if e.tag == "attribute" && e.attr("name") != Some("ADOM_PINMAP") { out.push(json!({"name": e.attr("name"), "value": e.attr("value").unwrap_or(""), "hidden": e.attr("display") == Some("off")})); } for c in &e.children { walk(c, out); } }
            walk(d, &mut params);
        }
    }
    params.dedup_by(|a, b| a["name"] == b["name"]);
    Ok(json!({"libref": ds.attr("name"), "description": ds.find_child("description").map(|d| d.text.trim().to_string()).unwrap_or_default(),
              "displayModeCount": 1, "parts": gates.len(), "pinsMode0": pins.len(), "pins": pins, "params": params, "models": models, "prefix": ds.attr("prefix")}))
}

/// The standard's find_symbol over library texts: (library label, text) pairs.
pub fn find_symbol(libs: &[(String, String)], query: &str, footprints: bool) -> Value {
    let q = query.to_lowercase();
    let mut matches = Vec::new();
    for (label, text) in libs {
        let Ok(mut root) = Element::parse(text) else { continue };
        let Ok(lib) = lib_mut(&mut root) else { continue };
        let (sec, tag) = if footprints { ("packages", "package") } else { ("devicesets", "deviceset") };
        for n in names(section(lib, sec), tag) { if n.to_lowercase().contains(&q) { matches.push(json!({"name": n, "library": label, "exact": n.eq_ignore_ascii_case(query)})); } }
    }
    let best = matches.iter().find(|m| m["exact"] == true).cloned().or_else(|| matches.iter().min_by_key(|m| m["name"].as_str().map(|s| s.len()).unwrap_or(usize::MAX)).cloned());
    json!({"matches": matches, "best": best})
}

fn primitives(e: &Element) -> usize { e.children.iter().filter(|c| c.tag != "description").count() }

/// copy_footprint (within a library) and xcopy_footprint (from another library's text).
pub fn copy_footprint(xml: &str, from_xml: Option<&str>, from: &str, to: &str) -> Result<(String, Value), String> {
    let src_text = from_xml.unwrap_or(xml);
    let mut src = Element::parse(src_text).map_err(|e| e.to_string())?;
    let pk = {
        let lib = lib_mut(&mut src)?;
        let sec = section(lib, "packages");
        let n = find_ci(&names(sec, "package"), from).cloned().ok_or_else(|| format!("no footprint {from}"))?;
        sec.children.iter().find(|p| p.tag == "package" && p.attr("name") == Some(n.as_str())).cloned().unwrap()
    };
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let lib = lib_mut(&mut root)?;
    let sec = section(lib, "packages");
    if find_ci(&names(sec, "package"), to).is_some() { return Err(format!("footprint {to} already exists in the destination")); }
    let mut c = pk; set(&mut c, "name", to); c.attrs.retain(|(k, _)| k != "urn" && k != "library_version");
    let n = primitives(&c);
    sec.children.push(c);
    Ok((root.to_xml_document(), json!({"from": from, "to": to, "primitives": n})))
}

/// rename_footprint: the package, every device that uses it, and its 3D package instances.
pub fn rename_footprint(xml: &str, from: &str, to: &str) -> Result<(String, Value), String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let lib = lib_mut(&mut root)?;
    let sec = section(lib, "packages");
    let n = find_ci(&names(sec, "package"), from).cloned().ok_or_else(|| format!("no footprint {from}"))?;
    if find_ci(&names(sec, "package"), to).is_some() && !n.eq_ignore_ascii_case(to) { return Err(format!("footprint {to} already exists")); }
    for p in sec.children.iter_mut().filter(|p| p.tag == "package" && p.attr("name") == Some(n.as_str())) { set(p, "name", to); }
    let mut devices = 0;
    fn walk(e: &mut Element, old: &str, new: &str, devices: &mut usize) {
        if e.tag == "device" && e.attr("package") == Some(old) { set(e, "package", new); *devices += 1; }
        if e.tag == "packageinstance" && e.attr("name") == Some(old) { set(e, "name", new); }
        for c in e.children.iter_mut() { walk(c, old, new, devices); }
    }
    walk(lib, &n, to, &mut devices);
    Ok((root.to_xml_document(), json!({"renamed": to, "from": n, "devicesUpdated": devices})))
}

/// remove_footprint: refused while a device uses it, so no symbol is left pointing at nothing.
pub fn remove_footprint(xml: &str, name: &str) -> Result<(String, Value), String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let lib = lib_mut(&mut root)?;
    let n = find_ci(&names(section(lib, "packages"), "package"), name).cloned().ok_or_else(|| format!("no footprint {name}"))?;
    let users: Vec<String> = section(lib, "devicesets").children.iter().filter(|d| d.find_child("devices").map(|ds| ds.children.iter().any(|x| x.attr("package") == Some(n.as_str()))).unwrap_or(false)).filter_map(|d| d.attr("name").map(str::to_string)).collect();
    if !users.is_empty() { return Err(format!("footprint {n} is used by {}: remove or relink those first", users.join(", "))); }
    section(lib, "packages").children.retain(|p| !(p.tag == "package" && p.attr("name") == Some(n.as_str())));
    Ok((root.to_xml_document(), json!({"removed": n})))
}

/// clone_symbol: the deviceset copied under a new name with copies of its symbols.
pub fn clone_symbol(xml: &str, from: &str, to: &str) -> Result<(String, Value), String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let lib = lib_mut(&mut root)?;
    let dsn = find_ci(&names(section(lib, "devicesets"), "deviceset"), from).cloned().ok_or_else(|| format!("no symbol {from}"))?;
    if find_ci(&names(section(lib, "devicesets"), "deviceset"), to).is_some() { return Err(format!("symbol {to} already exists")); }
    let mut ds = section(lib, "devicesets").children.iter().find(|d| d.tag == "deviceset" && d.attr("name") == Some(dsn.as_str())).cloned().unwrap();
    set(&mut ds, "name", to); ds.attrs.retain(|(k, _)| k != "urn" && k != "library_version");
    let syms = section(lib, "symbols").children.clone();
    let mut new_syms = Vec::new();
    let mut prims = 0;
    if let Some(g) = ds.find_child_mut("gates") {
        for gate in g.children.iter_mut().filter(|c| c.tag == "gate") {
            let old = gate.attr("symbol").unwrap_or("").to_string();
            if let Some(s) = syms.iter().find(|s| s.attr("name") == Some(old.as_str())) {
                let nn = if old.starts_with(&dsn) { format!("{to}{}", &old[dsn.len()..]) } else { format!("{to}_{old}") };
                let mut c = s.clone(); set(&mut c, "name", &nn); c.attrs.retain(|(k, _)| k != "urn" && k != "library_version");
                prims += primitives(&c);
                if !syms.iter().any(|x| x.attr("name") == Some(nn.as_str())) && !new_syms.iter().any(|x: &Element| x.attr("name") == Some(nn.as_str())) { new_syms.push(c); }
                set(gate, "symbol", &nn);
            }
        }
    }
    section(lib, "symbols").children.extend(new_syms);
    section(lib, "devicesets").children.push(ds);
    Ok((root.to_xml_document(), json!({"from": dsn, "to": to, "primitives": prims, "displayModeCount": 1})))
}

pub fn rename_symbol(xml: &str, from: &str, to: &str) -> Result<(String, Value), String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let lib = lib_mut(&mut root)?;
    let sec = section(lib, "devicesets");
    let n = find_ci(&names(sec, "deviceset"), from).cloned().ok_or_else(|| format!("no symbol {from}"))?;
    if find_ci(&names(sec, "deviceset"), to).is_some() && !n.eq_ignore_ascii_case(to) { return Err(format!("symbol {to} already exists")); }
    for d in sec.children.iter_mut().filter(|d| d.tag == "deviceset" && d.attr("name") == Some(n.as_str())) { set(d, "name", to); }
    Ok((root.to_xml_document(), json!({"renamed": to, "from": n})))
}

/// remove_symbol: the deviceset, and the symbols no other deviceset uses.
pub fn remove_symbol(xml: &str, name: &str) -> Result<(String, Value), String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let lib = lib_mut(&mut root)?;
    let n = find_ci(&names(section(lib, "devicesets"), "deviceset"), name).cloned().ok_or_else(|| format!("no symbol {name}"))?;
    let used_by = |sec: &Element, skip: &str| -> std::collections::HashSet<String> {
        sec.children.iter().filter(|d| d.tag == "deviceset" && d.attr("name") != Some(skip)).flat_map(|d| d.find_child("gates").map(|g| g.children.iter().filter_map(|x| x.attr("symbol").map(str::to_string)).collect::<Vec<_>>()).unwrap_or_default()).collect()
    };
    let mine: Vec<String> = section(lib, "devicesets").children.iter().filter(|d| d.attr("name") == Some(n.as_str())).flat_map(|d| d.find_child("gates").map(|g| g.children.iter().filter_map(|x| x.attr("symbol").map(str::to_string)).collect::<Vec<_>>()).unwrap_or_default()).collect();
    let others = used_by(section(lib, "devicesets"), &n);
    section(lib, "devicesets").children.retain(|d| !(d.tag == "deviceset" && d.attr("name") == Some(n.as_str())));
    let gone: Vec<String> = mine.into_iter().filter(|s| !others.contains(s)).collect();
    section(lib, "symbols").children.retain(|s| !(s.tag == "symbol" && s.attr("name").map(|x| gone.iter().any(|g| g == x)).unwrap_or(false)));
    Ok((root.to_xml_document(), json!({"removed": n, "symbolsRemoved": gone})))
}

/// A device's connects for a footprint: each pin joined, by pad NUMBER, to every pad of that number
/// on the footprint (N and its repeats N@2, N@3 ...). The pin numbers come from the component's
/// existing connects (a repeat's @k dropped) or its ADOM_PINMAP.
fn connects_for(ds: &Element, comp: &str, fp: &str, pk_pads: &[String]) -> Result<Element, String> {
    let base = |p: &str| p.split('@').next().unwrap_or(p).to_string();
    let mut by_pin: std::collections::BTreeMap<(String, String), Vec<String>> = Default::default();
    for (num, g, p) in pin_number_map(ds) { let v = by_pin.entry((g, p)).or_default(); let b = base(&num); if !v.contains(&b) { v.push(b); } }
    if by_pin.is_empty() { return Err(format!("{comp} has no pin numbers to join to {fp}'s pads")); }
    let mut cs = Element::new("connects");
    let mut missing = Vec::new();
    for ((g, p), nums) in &by_pin {
        let pads: Vec<&String> = pk_pads.iter().filter(|q| nums.iter().any(|n| base(q) == *n)).collect();
        if pads.is_empty() { missing.extend(nums.iter().cloned()); continue; }
        cs.children.push(el("connect", &[("gate", g.clone()), ("pin", p.clone()), ("pad", pads.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" "))]));
    }
    if !missing.is_empty() { return Err(format!("{fp} has no pad {}", missing.join(", "))); }
    Ok(cs)
}

/// The standard's set_models: the component keeps ONE footprint, every other device removed.
pub fn set_models(xml: &str, symbol: &str, footprint: &str) -> Result<(String, Value), String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let lib = lib_mut(&mut root)?;
    let pk_names = names(section(lib, "packages"), "package");
    let fp = find_ci(&pk_names, footprint).cloned().ok_or_else(|| format!("no footprint {footprint} in this library"))?;
    let pk_pads: Vec<String> = section(lib, "packages").children.iter().find(|p| p.attr("name") == Some(fp.as_str())).map(|p| p.children.iter().filter(|c| c.tag == "smd" || c.tag == "pad").filter_map(|c| c.attr("name").map(str::to_string)).collect()).unwrap_or_default();
    let sec = section(lib, "devicesets");
    let n = find_ci(&names(sec, "deviceset"), symbol).cloned().ok_or_else(|| format!("no symbol {symbol}"))?;
    let ds = sec.children.iter_mut().find(|d| d.tag == "deviceset" && d.attr("name") == Some(n.as_str())).unwrap();
    let cs = connects_for(ds, &n, &fp, &pk_pads)?;
    let devs = match ds.find_child_mut("devices") { Some(d) => d, None => { ds.children.push(Element::new("devices")); ds.find_child_mut("devices").unwrap() } };
    // the attributes ride along on the kept device
    let techs = devs.children.iter().find(|d| d.tag == "device").and_then(|d| d.find_child("technologies").cloned());
    let removed = devs.children.iter().filter(|d| d.tag == "device" && d.attr("package").map(|p| !p.is_empty()).unwrap_or(false)).count();
    devs.children.retain(|d| d.tag != "device");
    let mut dev = el("device", &[("name", "".into()), ("package", fp.clone())]);
    dev.children.push(cs);
    dev.children.push(techs.unwrap_or_else(|| { let mut t = Element::new("technologies"); t.children.push(el("technology", &[("name", "".into())])); t }));
    devs.children.push(dev);
    Ok((root.to_xml_document(), json!({"component": n, "footprint": fp, "removed": removed})))
}

/// The component (deviceset) and the symbols its gates draw, by name.
fn component_symbols(root: &mut Element, name: &str) -> Result<(String, Vec<String>), String> {
    let lib = lib_mut(root)?;
    let ds = section(lib, "devicesets").children.iter().find(|d| d.tag == "deviceset" && d.attr("name").map(|n| n.eq_ignore_ascii_case(name)).unwrap_or(false)).ok_or_else(|| format!("no symbol {name}"))?;
    let syms: Vec<String> = ds.find_child("gates").map(|g| g.children.iter().filter_map(|x| x.attr("symbol").map(str::to_string)).collect()).unwrap_or_default();
    Ok((ds.attr("name").unwrap_or(name).to_string(), syms))
}

/// The standard's symbol_prims: a component's drawing counted by kind, the body rectangle (the
/// symbol layer's lines and rectangles) and the polygons' box, in mil, and its footprints.
pub fn symbol_prims(xml: &str, name: &str) -> Result<Value, String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let (comp, syms) = component_symbols(&mut root, name)?;
    let lib = lib_mut(&mut root)?;
    let mil = |v: f64| (v / MIL * 10.0).round() / 10.0;
    let num = |e: &Element, k: &str| e.attr(k).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
    let (mut lines, mut rects, mut polys, mut circles, mut arcs, mut texts, mut pins) = (0, 0, 0, 0, 0, 0, 0);
    let (mut body, mut poly) = ((f64::MAX, f64::MAX, f64::MIN, f64::MIN), (f64::MAX, f64::MAX, f64::MIN, f64::MIN));
    let grow = |b: &mut (f64, f64, f64, f64), x: f64, y: f64| { b.0 = b.0.min(x); b.1 = b.1.min(y); b.2 = b.2.max(x); b.3 = b.3.max(y); };
    for sn in &syms {
        let Some(sym) = section(lib, "symbols").children.iter().find(|x| x.tag == "symbol" && x.attr("name") == Some(sn.as_str())) else { continue };
        for c in &sym.children {
            match c.tag.as_str() {
                "wire" if c.attr("curve").is_some() => arcs += 1,
                "wire" => { lines += 1; if c.attr("layer") == Some("94") { grow(&mut body, num(c, "x1"), num(c, "y1")); grow(&mut body, num(c, "x2"), num(c, "y2")); } }
                "rectangle" => { rects += 1; if c.attr("layer") == Some("94") { grow(&mut body, num(c, "x1"), num(c, "y1")); grow(&mut body, num(c, "x2"), num(c, "y2")); } }
                "polygon" => { polys += 1; for v in c.children.iter().filter(|v| v.tag == "vertex") { grow(&mut poly, num(v, "x"), num(v, "y")); } }
                "circle" => circles += 1,
                "text" => texts += 1,
                "pin" => pins += 1,
                _ => {}
            }
        }
    }
    let bx = |b: (f64, f64, f64, f64)| if b.0 == f64::MAX { Value::Null } else { json!([mil(b.0), mil(b.1), mil(b.2), mil(b.3)]) };
    let ds = section(lib, "devicesets").children.iter().find(|d| d.attr("name") == Some(comp.as_str())).unwrap();
    let models: Vec<Value> = ds.find_child("devices").map(|d| d.children.iter().filter_map(|x| x.attr("package").filter(|p| !p.is_empty()).map(|p| json!({"type": "PCBLIB", "name": p, "device": x.attr("name").unwrap_or("")}))).collect()).unwrap_or_default();
    Ok(json!({"symbol": comp, "symbols": syms, "pins": pins, "lines": lines, "rectangles": rects, "polylines": polys, "circles": circles, "arcs": arcs, "texts": texts,
              "bodyRectMil": bx(body), "polyBboxMil": bx(poly), "displayModes": 1, "models": models}))
}

/// The standard's set_visibility: an EAGLE component shows a parameter on the sheet when its symbol
/// carries a >NAME text for it, so showing one adds that text (Info layer, below the body) to each
/// symbol the component's gates draw, and hiding one removes it.
pub fn set_visibility(xml: &str, name: &str, parameter: &str, visible: bool) -> Result<(String, Value), String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let (comp, syms) = component_symbols(&mut root, name)?;
    let key = format!(">{}", parameter.trim_start_matches('>').to_uppercase());
    let lib = lib_mut(&mut root)?;
    let mut changed = Vec::new();
    for sn in &syms {
        let Some(sym) = section(lib, "symbols").children.iter_mut().find(|x| x.tag == "symbol" && x.attr("name") == Some(sn.as_str())) else { continue };
        let has = sym.children.iter().any(|c| c.tag == "text" && c.text.eq_ignore_ascii_case(&key));
        if visible && !has {
            let (mut x0, mut y0) = (f64::MAX, f64::MAX);
            for c in &sym.children { for (kx, ky) in [("x1", "y1"), ("x2", "y2"), ("x", "y")] { if let (Some(x), Some(y)) = (c.attr(kx).and_then(|v| v.parse::<f64>().ok()), c.attr(ky).and_then(|v| v.parse::<f64>().ok())) { x0 = x0.min(x); y0 = y0.min(y); } } }
            // below whatever the symbol already writes there (>VALUE and other shown parameters)
            let shown = sym.children.iter().filter(|c| c.tag == "text" && c.text.starts_with('>')).count() as f64;
            let mut t = el("text", &[("x", f(if x0 == f64::MAX { 0.0 } else { x0 })), ("y", f(if y0 == f64::MAX { 0.0 } else { y0 } - 2.54 * shown)), ("size", "1.778".into()), ("layer", "97".into())]);
            t.text = key.clone();
            sym.children.push(t);
            changed.push(sn.clone());
        } else if !visible && has {
            sym.children.retain(|c| !(c.tag == "text" && c.text.eq_ignore_ascii_case(&key)));
            changed.push(sn.clone());
        }
    }
    Ok((root.to_xml_document(), json!({"component": comp, "parameter": key.trim_start_matches('>'), "visible": visible, "symbolsChanged": changed})))
}

/// A KiCad symbol's body rectangle (mm, x0 y0 x1 y1) and polylines, from a .kicad_sym (the symbol
/// and its unit blocks, which nest inside it).
fn kicad_art(text: &str, name: &str) -> (Option<(f64, f64, f64, f64)>, Vec<Vec<(f64, f64)>>) {
    let head = format!("(symbol \"{name}\"");
    let Some(start) = text.find(&head) else { return (None, vec![]) };
    let b = text.as_bytes(); let (mut depth, mut end) = (0i32, text.len());
    for (i, &c) in b.iter().enumerate().skip(start) { if c == b'(' { depth += 1; } else if c == b')' { depth -= 1; if depth == 0 { end = i + 1; break; } } }
    let blk = &text[start..end];
    let num = |t: &str| t.trim().parse::<f64>().ok();
    let rect = regex::Regex::new(r"\(rectangle\s*\(start\s+([-\d.]+)\s+([-\d.]+)\)\s*\(end\s+([-\d.]+)\s+([-\d.]+)\)").ok()
        .and_then(|re| re.captures(blk)).and_then(|c| Some((num(&c[1])?, num(&c[2])?, num(&c[3])?, num(&c[4])?)))
        .map(|(a, b2, c, d)| (a.min(c), b2.min(d), a.max(c), b2.max(d)));
    let xy = regex::Regex::new(r"\(xy\s+([-\d.]+)\s+([-\d.]+)\)").unwrap();
    let mut polys = Vec::new();
    let mut rest = blk;
    while let Some(i) = rest.find("(polyline") {
        let sub = &rest[i..]; let sb = sub.as_bytes(); let (mut dep, mut e) = (0i32, sub.len());
        for (j, &c) in sb.iter().enumerate() { if c == b'(' { dep += 1; } else if c == b')' { dep -= 1; if dep == 0 { e = j + 1; break; } } }
        let pts: Vec<(f64, f64)> = xy.captures_iter(&sub[..e]).filter_map(|c| Some((num(&c[1])?, num(&c[2])?))).collect();
        if pts.len() >= 2 { polys.push(pts); }
        rest = &sub[e..];
    }
    (rect, polys)
}

/// The standard's add_symbol_art: the chip-outline artwork adom-symbol baked into a KiCad symbol,
/// drawn into the EAGLE component's symbol(s) on the symbol layer, the KiCad body rectangle mapped
/// onto the symbol's own body box. clear (default) removes earlier art first: every symbol-layer
/// wire and polygon except the body's own edges.
pub fn add_symbol_art(xml: &str, name: &str, kicad_sym: &str, clear: bool) -> Result<(String, Value), String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let (comp, syms) = component_symbols(&mut root, name)?;
    // the KiCad symbol by the component's own name (as asked, when it differs in case)
    let (rect, polys) = { let a = kicad_art(kicad_sym, &comp); if a.0.is_some() { a } else { kicad_art(kicad_sym, name) } };
    let kr = rect.ok_or_else(|| format!("no (rectangle ...) body for symbol {comp} in the .kicad_sym"))?;
    if polys.is_empty() { return Err(format!("no polylines in symbol {comp}: bake the outline first (adom-symbol create --outline-svg <mpn>-3d-outline-heavy.svg --outline-name none)")); }
    let lib = lib_mut(&mut root)?;
    let num = |e: &Element, k: &str| e.attr(k).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
    let (mut drawn, mut removed) = (0usize, 0usize);
    let mut body_out = Value::Null;
    for sn in &syms {
        let Some(sym) = section(lib, "symbols").children.iter_mut().find(|x| x.tag == "symbol" && x.attr("name") == Some(sn.as_str())) else { continue };
        // the body box: the symbol layer's lines and rectangles
        let mut bb = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
        for c in sym.children.iter().filter(|c| (c.tag == "wire" || c.tag == "rectangle") && c.attr("layer") == Some("94")) {
            for (x, y) in [(num(c, "x1"), num(c, "y1")), (num(c, "x2"), num(c, "y2"))] { bb.0 = bb.0.min(x); bb.1 = bb.1.min(y); bb.2 = bb.2.max(x); bb.3 = bb.3.max(y); }
        }
        if bb.0 == f64::MAX { return Err(format!("symbol {sn} has no body (no lines on the symbol layer) to fit the art to")); }
        if clear {
            let on_edge = |c: &Element| {
                let (x1, y1, x2, y2) = (num(c, "x1"), num(c, "y1"), num(c, "x2"), num(c, "y2"));
                let e = 1e-6;
                ((x1 - x2).abs() < e && ((x1 - bb.0).abs() < e || (x1 - bb.2).abs() < e)) || ((y1 - y2).abs() < e && ((y1 - bb.1).abs() < e || (y1 - bb.3).abs() < e))
            };
            let before = sym.children.len();
            sym.children.retain(|c| !(c.attr("layer") == Some("94") && ((c.tag == "wire" && !on_edge(c)) || c.tag == "polygon")));
            removed += before - sym.children.len();
        }
        let (sx, sy) = ((bb.2 - bb.0) / (kr.2 - kr.0).max(1e-9), (bb.3 - bb.1) / (kr.3 - kr.1).max(1e-9));
        let map = |(x, y): (f64, f64)| (bb.0 + (x - kr.0) * sx, bb.1 + (y - kr.1) * sy);
        for pl in &polys {
            for w in pl.windows(2) {
                let (a, b) = (map(w[0]), map(w[1]));
                if (a.0 - b.0).abs() + (a.1 - b.1).abs() < 1e-9 { continue; }
                sym.children.push(el("wire", &[("x1", f(a.0)), ("y1", f(a.1)), ("x2", f(b.0)), ("y2", f(b.1)), ("width", "0.1524".into()), ("layer", "94".into())]));
                drawn += 1;
            }
        }
        let mm2mil = |v: f64| (v / MIL * 10.0).round() / 10.0;
        body_out = json!([mm2mil(bb.0), mm2mil(bb.1), mm2mil(bb.2), mm2mil(bb.3)]);
    }
    Ok((root.to_xml_document(), json!({"component": comp, "polylines": polys.len(), "points": polys.iter().map(|p| p.len()).sum::<usize>(), "lines": drawn, "removed": removed, "bodyRectMil": body_out})))
}

/// The standard's lint_library input, read from an EAGLE library: each package as a footprint
/// (pads in its own frame, mm; holes as mechanical pads; the courtyard from tKeepout, silk from
/// tPlace, its 3D packages, its description) and each component as a symbol per device (pin numbers
/// from the device's connects, its package, the MPN attribute). The engine judges them.
pub fn lint_input(xml: &str) -> Result<Value, String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let lib = lib_mut(&mut root)?;
    let num = |e: &Element, k: &str| e.attr(k).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
    let turned = |e: &Element| e.attr("rot").map(|r| { let d: f64 = r.trim_start_matches(['M', 'S', 'R']).parse().unwrap_or(0.0); ((d / 90.0).round() as i64).rem_euclid(2) == 1 }).unwrap_or(false);
    // which packages a 3D package instances
    let mut bodies: std::collections::BTreeMap<String, Vec<Value>> = Default::default();
    if let Some(p3) = lib.find_child("packages3d") {
        for d in p3.children.iter().filter(|c| c.tag == "package3d") {
            for pi in d.find_child("packageinstances").map(|x| x.children.clone()).unwrap_or_default() {
                if let Some(n) = pi.attr("name") { bodies.entry(n.to_string()).or_default().push(json!({"name": d.attr("name"), "urn": d.attr("urn").or(d.attr("wip_urn"))})); }
            }
        }
    }
    let mut fps = Vec::new();
    for pk in section(lib, "packages").children.iter().filter(|c| c.tag == "package") {
        let name = pk.attr("name").unwrap_or("").to_string();
        let mut pads = Vec::new();
        let (mut court, mut silk) = (Vec::new(), 0usize);
        for c in &pk.children {
            match c.tag.as_str() {
                "smd" => { let (w, h) = (num(c, "dx"), num(c, "dy")); let (w, h) = if turned(c) { (h, w) } else { (w, h) };
                           pads.push(json!({"number": c.attr("name").unwrap_or(""), "x": num(c, "x"), "y": num(c, "y"), "w": w, "h": h})); }
                "pad" => { let d = if c.attr("diameter").is_some() { num(c, "diameter") } else { num(c, "drill") * 1.6 };
                           pads.push(json!({"number": c.attr("name").unwrap_or(""), "x": num(c, "x"), "y": num(c, "y"), "w": d, "h": d, "hole": num(c, "drill")})); }
                "hole" => pads.push(json!({"number": "", "x": num(c, "x"), "y": num(c, "y"), "w": num(c, "drill"), "h": num(c, "drill"), "hole": num(c, "drill"), "mechanical": true, "plated": false})),
                _ => {}
            }
            match c.attr("layer") {
                Some("39") => {
                    for (kx, ky) in [("x1", "y1"), ("x2", "y2"), ("x", "y")] { if c.attr(kx).is_some() { court.push(json!([num(c, kx), num(c, ky)])); } }
                    for v in c.children.iter().filter(|v| v.tag == "vertex") { court.push(json!([num(v, "x"), num(v, "y")])); }
                }
                Some("21") => silk += 1,
                _ => {}
            }
        }
        fps.push(json!({"name": name, "pads": pads, "courtyard": court, "silk": if silk > 0 { json!([silk]) } else { json!([]) },
                        "bodies": bodies.get(&name).cloned().unwrap_or_default(), "description": pk.find_child("description").map(|d| d.text.trim().to_string()).unwrap_or_default()}));
    }
    let mut syms = Vec::new();
    let _ = section(lib, "symbols");
    let sets: Vec<Element> = section(lib, "devicesets").children.iter().filter(|c| c.tag == "deviceset").cloned().collect();
    let lib = &*lib;
    for ds in &sets {
        let dsn = ds.attr("name").unwrap_or("").to_string();
        let devs: Vec<&Element> = ds.find_child("devices").map(|d| d.children.iter().filter(|x| x.tag == "device").collect()).unwrap_or_default();
        let many = devs.len() > 1;
        for dev in devs {
            let pkg = dev.attr("package").unwrap_or("").to_string();
            let mut pins: Vec<Value> = Vec::new();
            for c in dev.find_child("connects").map(|x| x.children.iter().filter(|c| c.tag == "connect").collect::<Vec<_>>()).unwrap_or_default() {
                for pad in c.attr("pad").unwrap_or("").split_whitespace() { pins.push(json!({"number": pad.split('@').next().unwrap_or(pad), "name": c.attr("pin")})); }
            }
            if pins.is_empty() {
                // a device with no footprint has no connects: its pins carry their numbers in
                // ADOM_PINMAP ("1:G$1.IN;..."), else they are the gates' symbol pins, unnumbered
                let mut map = String::new();
                let mut w = vec![dev];
                while let Some(e) = w.pop() { for c in &e.children { if c.tag == "attribute" && c.attr("name") == Some("ADOM_PINMAP") { map = c.attr("value").unwrap_or("").to_string(); } w.push(c); } }
                if !map.is_empty() {
                    for item in map.split(';').filter(|x| !x.is_empty()) { if let Some((n, gp)) = item.split_once(':') { pins.push(json!({"number": n, "name": gp.split_once('.').map(|x| x.1).unwrap_or(gp)})); } }
                } else {
                    let gates: Vec<String> = ds.find_child("gates").map(|g| g.children.iter().filter_map(|x| x.attr("symbol").map(str::to_string)).collect()).unwrap_or_default();
                    for g in gates {
                        if let Some(sym) = lib.find_child("symbols").and_then(|sec| sec.children.iter().find(|x| x.attr("name") == Some(g.as_str()))) {
                            for p in sym.children.iter().filter(|c| c.tag == "pin") { pins.push(json!({"number": "", "name": p.attr("name")})); }
                        }
                    }
                }
            }
            let mut mpn = String::new();
            let mut walk = vec![dev];
            while let Some(e) = walk.pop() {
                for c in &e.children {
                    if c.tag == "attribute" && ["MPN", "MANUFACTURER_PART_NUMBER", "MANUFACTURER PART NUMBER", "PARTNUMBER", "PART_NUMBER"].contains(&c.attr("name").unwrap_or("").to_uppercase().as_str()) {
                        if let Some(v) = c.attr("value").filter(|v| !v.trim().is_empty()) { mpn = v.to_string(); }
                    }
                    walk.push(c);
                }
            }
            let name = if many { format!("{dsn}{}", dev.attr("name").unwrap_or("")) } else { dsn.clone() };
            syms.push(json!({"name": name, "pins": pins, "footprint": pkg, "partNumber": mpn}));
        }
    }
    Ok(json!({"footprints": fps, "symbols": syms}))
}

/// The standard's add_models: each footprint becomes another device of the component (EAGLE's
/// several-packages-per-component), pins joined by pad number; a footprint already attached is
/// skipped, since a device names it once. `sources` holds other .lbr texts a model's footprint is
/// copied from. Then params, comment and description.
pub fn add_models(xml: &str, symbol: &str, models: &[(String, Option<String>)], params: &Value, comment: Option<&str>, description: Option<&str>) -> Result<(String, Value), String> {
    let mut text = xml.to_string();
    let (mut added, mut skipped) = (Vec::new(), Vec::new());
    for (fp, source) in models {
        // a footprint from another library comes in first
        let have = { let mut r = Element::parse(&text).map_err(|e| e.to_string())?; let l = lib_mut(&mut r)?; find_ci(&names(section(l, "packages"), "package"), fp).is_some() };
        if !have {
            let src = source.as_deref().ok_or_else(|| format!("no footprint {fp} in this library (give the model's library)"))?;
            text = copy_footprint(&text, Some(src), fp, fp)?.0;
        }
        let attached = {
            let mut r = Element::parse(&text).map_err(|e| e.to_string())?; let l = lib_mut(&mut r)?;
            let sec = section(l, "devicesets");
            let ds = sec.children.iter().find(|d| d.tag == "deviceset" && d.attr("name").map(|n| n.eq_ignore_ascii_case(symbol)).unwrap_or(false)).ok_or_else(|| format!("no symbol {symbol}"))?;
            ds.find_child("devices").map(|d| d.children.iter().any(|x| x.attr("package").map(|p| p.eq_ignore_ascii_case(fp)).unwrap_or(false))).unwrap_or(false)
        };
        if attached { skipped.push(fp.clone()); continue; }
        text = set_params(&text, Some(symbol), &Value::Null, None, Some(fp))?.0;
        added.push(fp.clone());
    }
    let (mut t, s) = set_params(&text, Some(symbol), params, comment, None)?;
    if let Some(d) = description.filter(|d| !d.is_empty()) {
        let mut r = Element::parse(&t).map_err(|e| e.to_string())?;
        { let l = lib_mut(&mut r)?; let sec = section(l, "devicesets");
          if let Some(ds) = sec.children.iter_mut().find(|x| x.tag == "deviceset" && x.attr("name").map(|n| n.eq_ignore_ascii_case(symbol)).unwrap_or(false)) {
              ds.children.retain(|c| c.tag != "description"); let mut de = Element::new("description"); de.text = d.into(); ds.children.insert(0, de); } }
        t = r.to_xml_document();
    }
    let models_now = { let mut r = Element::parse(&t).map_err(|e| e.to_string())?; let l = lib_mut(&mut r)?; let sec = section(l, "devicesets");
        sec.children.iter().find(|d| d.attr("name").map(|n| n.eq_ignore_ascii_case(symbol)).unwrap_or(false)).and_then(|d| d.find_child("devices")).map(|d| d.children.iter().filter(|x| x.attr("package").map(|p| !p.is_empty()).unwrap_or(false)).count()).unwrap_or(0) };
    Ok((t, json!({"libref": s["component"], "models": models_now, "added": added, "alreadyAttached": skipped, "params": s["paramsSet"]})))
}

/// The standard's set_params: attributes on every device of the symbol (EAGLE keeps them per
/// technology), a COMMENT attribute, and with `footprint` a device on that package whose connects
/// come from the pin numbers (pad name = pin number).
pub fn set_params(xml: &str, symbol: Option<&str>, params: &Value, comment: Option<&str>, footprint: Option<&str>) -> Result<(String, Value), String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let lib = lib_mut(&mut root)?;
    let pk_names = names(section(lib, "packages"), "package");
    let pk_pads: Vec<String> = match footprint {
        Some(fp) => {
            let n = find_ci(&pk_names, fp).cloned().ok_or_else(|| format!("no footprint {fp} in this library"))?;
            section(lib, "packages").children.iter().find(|p| p.attr("name") == Some(n.as_str())).map(|p| p.children.iter().filter(|c| c.tag == "smd" || c.tag == "pad").filter_map(|c| c.attr("name").map(str::to_string)).collect()).unwrap_or_default()
        }
        None => vec![],
    };
    let fp_name = footprint.and_then(|fp| find_ci(&pk_names, fp).cloned());
    let sec = section(lib, "devicesets");
    let all = names(sec, "deviceset");
    let n = match symbol { Some(s) => find_ci(&all, s).cloned().ok_or_else(|| format!("no symbol {s}"))?, None => all.first().cloned().ok_or("the library has no symbols")? };
    let ds = sec.children.iter_mut().find(|d| d.tag == "deviceset" && d.attr("name") == Some(n.as_str())).unwrap();
    let mut kv: Vec<(String, String)> = match params {
        Value::Object(m) => m.iter().map(|(k, v)| (k.to_uppercase(), v.as_str().map(str::to_string).unwrap_or(v.to_string()))).collect(),
        Value::String(s) => s.split(';').filter_map(|p| p.split_once('=')).map(|(k, v)| (k.trim().to_uppercase(), v.trim().to_string())).collect(),
        _ => vec![],
    };
    if let Some(c) = comment { kv.push(("COMMENT".into(), c.to_string())); }
    let mut linked = Value::Null;
    if let (Some(fp), false) = (&fp_name, pk_pads.is_empty()) {
        let cs = connects_for(ds, &n, fp, &pk_pads)?;
        let devs = match ds.find_child_mut("devices") { Some(d) => d, None => { ds.children.push(Element::new("devices")); ds.find_child_mut("devices").unwrap() } };
        // the package-less device that held the pin numbers becomes the footprint's device
        let idx = devs.children.iter().position(|d| d.tag == "device" && d.attr("package").map(|p| p.is_empty()).unwrap_or(true));
        let mut dev = match idx { Some(i) => devs.children.remove(i), None => el("device", &[("name", if devs.children.is_empty() { "".into() } else { format!("-{}", fp) })]) };
        set(&mut dev, "package", fp);
        dev.children.retain(|c| c.tag != "connects");
        dev.children.insert(0, cs);
        devs.children.push(dev);
        linked = json!(fp);
    }
    let mut set_n = 0;
    if let Some(devs) = ds.find_child_mut("devices") {
        if devs.children.is_empty() { devs.children.push(el("device", &[("name", "".into())])); }
        for d in devs.children.iter_mut().filter(|c| c.tag == "device") {
            let techs = match d.find_child_mut("technologies") { Some(t) => t, None => { d.children.push(Element::new("technologies")); d.find_child_mut("technologies").unwrap() } };
            if techs.children.is_empty() { techs.children.push(el("technology", &[("name", "".into())])); }
            for t in techs.children.iter_mut().filter(|c| c.tag == "technology") {
                for (k, v) in &kv {
                    match t.children.iter_mut().find(|a| a.tag == "attribute" && a.attr("name") == Some(k.as_str())) {
                        Some(a) => set(a, "value", v),
                        None => t.children.push(el("attribute", &[("name", k.clone()), ("value", v.clone()), ("constant", "no".into())])),
                    }
                }
            }
        }
        set_n = kv.len();
    }
    Ok((root.to_xml_document(), json!({"component": n, "paramsSet": set_n, "footprint": linked})))
}
/// The standard's plate_vias: in EAGLE a pad is always plated, and an unplated drill is a <hole>.
/// Each hole in a footprint (or one footprint) becomes a plated pad. A hole inside an SMD land (a
/// heatsink via in an exposed pad) is named after that land, N@k, and joined to its connects in every
/// device, so it is on the land's net; any other hole becomes H<k>, unconnected.
pub fn plate_vias(xml: &str, footprint: Option<&str>) -> Result<(String, Value), String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let lib = lib_mut(&mut root)?;
    let fp = match footprint { Some(w) => Some(find_ci(&names(section(lib, "packages"), "package"), w).cloned().ok_or_else(|| format!("no footprint {w}"))?), None => None };
    let num = |e: &Element, k: &str| e.attr(k).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
    let (mut holes, mut fixed, mut joins) = (0usize, Vec::new(), Vec::<(String, String, String)>::new());
    for pk in section(lib, "packages").children.iter_mut().filter(|p| p.tag == "package" && fp.as_deref().map(|n| p.attr("name") == Some(n)).unwrap_or(true)) {
        let pname = pk.attr("name").unwrap_or("").to_string();
        let mut taken: std::collections::HashSet<String> = pk.children.iter().filter(|c| c.tag == "pad" || c.tag == "smd").filter_map(|c| c.attr("name").map(str::to_string)).collect();
        // each SMD land as its half extents round its centre (a turned land by its bounding box)
        let lands: Vec<(String, f64, f64, f64, f64)> = pk.children.iter().filter(|c| c.tag == "smd").map(|s| {
            let deg = s.attr("rot").map(|r| r.trim_start_matches(|c: char| c.is_ascii_alphabetic()).parse::<f64>().unwrap_or(0.0)).unwrap_or(0.0).to_radians();
            let (hw, hh) = (num(s, "dx") / 2.0, num(s, "dy") / 2.0);
            (s.attr("name").unwrap_or("").to_string(), num(s, "x"), num(s, "y"), hw * deg.cos().abs() + hh * deg.sin().abs(), hw * deg.sin().abs() + hh * deg.cos().abs())
        }).collect();
        for c in pk.children.iter_mut().filter(|c| c.tag == "hole") {
            holes += 1;
            let (x, y) = (num(c, "x"), num(c, "y"));
            let land = lands.iter().find(|(_, lx, ly, hw, hh)| (x - lx).abs() <= *hw && (y - ly).abs() <= *hh).map(|l| l.0.clone());
            let stem = land.clone().map(|l| format!("{}@", l.split('@').next().unwrap_or(&l))).unwrap_or_else(|| "H".into());
            let name = (if land.is_some() { 2 } else { 1 }..).map(|k| format!("{stem}{k}")).find(|n| !taken.contains(n)).unwrap();
            taken.insert(name.clone());
            let drill = c.attr("drill").unwrap_or("0").to_string();
            // no diameter: the board's restring rules size the copper ring, as for any EAGLE via
            *c = el("pad", &[("name", name.clone()), ("x", f(x)), ("y", f(y)), ("drill", drill)]);
            fixed.push(format!("{pname}:{name}"));
            if let Some(l) = land { joins.push((pname.clone(), l, name)); }
        }
    }
    // a via named after its land joins the connect that land is on, in every device of that footprint
    for d in section(lib, "devicesets").children.iter_mut().filter(|d| d.tag == "deviceset") {
        for dev in d.find_child_mut("devices").map(|x| x.children.iter_mut().collect::<Vec<_>>()).unwrap_or_default() {
            let pk = dev.attr("package").unwrap_or("").to_string();
            for cn in dev.find_child_mut("connects").map(|x| x.children.iter_mut().collect::<Vec<_>>()).unwrap_or_default() {
                let mut pads: Vec<String> = cn.attr("pad").unwrap_or("").split_whitespace().map(str::to_string).collect();
                let add: Vec<String> = joins.iter().filter(|(p, l, _)| *p == pk && pads.contains(l)).map(|j| j.2.clone()).collect();
                if add.is_empty() { continue; }
                pads.extend(add);
                set(cn, "pad", &pads.join(" "));
            }
        }
    }
    let plated = fixed.len();
    Ok((root.to_xml_document(), json!({"holes": holes, "plated": plated, "fixed": fixed.join(";"), "joinedToLands": joins.len(),
        "note": if holes == 0 { "no unplated holes: every drilled pad in an EAGLE footprint is already plated" } else { "each hole is now a plated pad; one inside an SMD land is on that land's net" }})))
}

/// The standard's cleanup_libs: the empty leftovers a library collects removed: components with no
/// device, and footprints and symbols with nothing drawn that nothing uses. Anything with content stays.
pub fn cleanup_libs(xml: &str) -> Result<(String, Value), String> {
    let mut root = Element::parse(xml).map_err(|e| e.to_string())?;
    let lib = lib_mut(&mut root)?;
    let mut removed: Vec<String> = Vec::new();
    let ds = section(lib, "devicesets");
    ds.children.retain(|d| {
        let empty = d.tag == "deviceset" && d.find_child("devices").map(|x| x.children.iter().all(|c| c.tag != "device")).unwrap_or(true);
        if empty { removed.push(format!("component {}", d.attr("name").unwrap_or(""))); }
        !empty
    });
    let used_sym: std::collections::HashSet<String> = ds.children.iter().flat_map(|d| d.find_child("gates").map(|g| g.children.iter().filter_map(|x| x.attr("symbol").map(str::to_string)).collect::<Vec<_>>()).unwrap_or_default()).collect();
    let used_pk: std::collections::HashSet<String> = ds.children.iter().flat_map(|d| d.find_child("devices").map(|g| g.children.iter().filter_map(|x| x.attr("package").map(str::to_string)).collect::<Vec<_>>()).unwrap_or_default()).collect();
    let drawn = |e: &Element| e.children.iter().any(|c| c.tag != "description");
    for (sec, tag, used, what) in [("symbols", "symbol", &used_sym, "symbol"), ("packages", "package", &used_pk, "footprint")] {
        section(lib, sec).children.retain(|e| {
            let gone = e.tag == tag && !drawn(e) && !e.attr("name").map(|n| used.contains(n)).unwrap_or(false);
            if gone { removed.push(format!("{what} {}", e.attr("name").unwrap_or(""))); }
            !gone
        });
    }
    let n = removed.len();
    Ok((root.to_xml_document(), json!({"removed": n, "items": removed})))
}


/// The preflight a library under construction must pass: every check of fusion_preflight_lbr
/// except the ones only a finished library meets (has symbols, devicesets, packages, connects):
/// a footprint-only library is fine to write; opening a component in it is what needs them.
pub fn preflight_text(path: &std::path::Path) -> (bool, Vec<String>) {
    let pf = crate::preflight_lbr(path);
    // also fine mid-build: a symbol not yet joined to a footprint (no connects anywhere yet), and a
    // library whose last footprint was just removed
    let errs: Vec<String> = pf.errors.into_iter().filter(|e| !e.starts_with("symbols-present") && !e.starts_with("devicesets-present")
        && !e.starts_with("connects-resolve: no connects at all") && !e.starts_with("packages-nonempty")).collect();
    (errs.is_empty(), errs)
}

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

    #[test]
    fn a_library_is_read_for_the_engine_and_judged() {
        let sym = "META|LDO-X|reg|U\nMODE|0|n\nRECT|0|0|-300|600|0\nPIN|0|1|IN|0|-100|left|300||power_in\nPIN|0|2|OUT|600|-100|right|300||power_out\n";
        let fp = "META|SOT-X|x\nPAD|1|-1|0|1|0.6|rect|0|top|0\nPAD|2|1|0|1|0.6|rect|0|top|0\nLINE|courtyard|-2|-1|2|1|0.05\n";
        let (xml, _) = crate::import::import_part(None, Some(fp), Some(sym), None, &json!({"params": {"MPN": "LDO-X-1"}})).unwrap();
        // a footprint-less component keeps its pin numbers in ADOM_PINMAP
        let (bare, _) = create_symbol(None, &json!({"name": "BARE", "pins": "1,A,left,100;2,B,right,100"})).unwrap();
        let b = lint_input(&bare).unwrap();
        assert_eq!(b["symbols"][0]["pins"].as_array().unwrap().len(), 2, "{b}");
        assert_eq!((b["symbols"][0]["pins"][0]["number"].as_str(), b["symbols"][0]["footprint"].as_str()), (Some("1"), Some("")));
        let lib = lint_input(&xml).unwrap();
        assert_eq!(lib["footprints"][0]["pads"].as_array().unwrap().len(), 2);
        assert_eq!(lib["symbols"][0]["partNumber"], "LDO-X-1");
        assert_eq!(lib["symbols"][0]["footprint"], "SOT-X");
        assert_eq!(lib["footprints"][0]["silk"], json!([]), "no silk drawn: sent empty, so the engine reports it");
        assert_eq!(lib["footprints"][0]["bodies"], json!([]));
        assert_eq!(lib["footprints"][0]["courtyard"].as_array().unwrap().len(), 2);
        assert_eq!(lib["symbols"][0]["pins"].as_array().unwrap().len(), 2);
    }
}

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

    #[test]
    fn kicad_art_is_fitted_to_the_eagle_body() {
        let sym = "META|IND-X|ind|L\nMODE|0|n\nRECT|0|0|-400|800|0\nPIN|0|1|1|0|-200|left|300||passive\nPIN|0|2|2|800|-200|right|300||passive\n";
        let fp = "META|IND-FP|x\nPAD|1|-1|0|1|1|rect|0|top|0\nPAD|2|1|0|1|1|rect|0|top|0\n";
        let (xml, _) = crate::import::import_part(None, Some(fp), Some(sym), None, &json!({})).unwrap();
        let ks = "(kicad_symbol_lib (symbol \"IND-X\" (symbol \"IND-X_0_1\" (rectangle (start -2 2) (end 2 -2)) (polyline (pts (xy -2 0) (xy 0 2) (xy 2 0)))) ))";
        let (out, r) = add_symbol_art(&xml, "ind-x", ks, true).unwrap();
        assert_eq!((r["polylines"].as_u64(), r["lines"].as_u64(), r["removed"].as_u64()), (Some(1), Some(2), Some(0)), "{r}");
        // KiCad (-2,0) -> the body's left edge at mid height: x 0, y -200 mil; (0,2) -> top middle
        assert!(out.contains(r#"<wire x1="0" y1="-5.08" x2="10.16" y2="0" width="0.1524" layer="94" />"#), "{out}");
        // again with clear: the old art goes, the body edges stay
        let (_, r2) = add_symbol_art(&out, "IND-X", ks, true).unwrap();
        assert_eq!(r2["removed"].as_u64(), Some(2));
        assert!(add_symbol_art(&xml, "IND-X", "(kicad_symbol_lib (symbol \"IND-X\" (rectangle (start 0 0) (end 1 1))))", true).is_err());
    }
}

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

    #[test]
    fn prims_count_the_drawing_and_visibility_toggles_a_text() {
        let sym = "META|LDO-X|reg|U\nMODE|0|n\nRECT|0|0|-300|600|0\nLINE|0|0|0|100|100\nPIN|0|1|IN|0|-100|left|300||power_in\nPIN|0|2|OUT|600|-100|right|300||power_out\n";
        let fp = "META|SOT-X|x\nPAD|1|-1|0|1|1|rect|0|top|0\nPAD|2|1|0|1|1|rect|0|top|0\n";
        let (xml, _) = crate::import::import_part(None, Some(fp), Some(sym), None, &json!({})).unwrap();
        let p = symbol_prims(&xml, "ldo-x").unwrap();
        assert_eq!((p["pins"].as_u64(), p["lines"].as_u64(), p["models"][0]["name"].as_str()), (Some(2), Some(5), Some("SOT-X")));
        assert_eq!(p["bodyRectMil"], json!([0.0, -300.0, 600.0, 100.0]));
        let (shown, r) = set_visibility(&xml, "LDO-X", "mpn", true).unwrap();
        assert_eq!(r["symbolsChanged"][0], "LDO-X");
        assert!(shown.contains(">MPN</text>") || shown.contains("&gt;MPN</text>"), "{shown}");
        let (again, r2) = set_visibility(&shown, "LDO-X", "MPN", true).unwrap();
        assert_eq!(r2["symbolsChanged"].as_array().unwrap().len(), 0, "already shown");
        let (hidden, _) = set_visibility(&again, "LDO-X", "MPN", false).unwrap();
        assert!(!hidden.contains("MPN</text>"));
    }
}

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

    #[test]
    fn models_are_added_once_and_set_keeps_one() {
        let fp = "META|A2|two\nPAD|1|-1|0|1|1|rect|0|top|0\nPAD|2|1|0|1|1|rect|0|top|0\nPAD|2|1|1|0.5|0.5|rect|0|top|0\n";
        let fp_b = "META|B2|two\nPAD|1|-2|0|1|1|rect|0|top|0\nPAD|2|2|0|1|1|rect|0|top|0\n";
        let sym = "META|R-X|res|R\nMODE|0|n\nPIN|0|1|1|0|0|left|100||passive\nPIN|0|2|2|200|0|right|100||passive\n";
        let (xml, _) = crate::import::import_part(None, Some(fp), Some(sym), None, &json!({})).unwrap();
        let (other, _) = crate::import::import_part(None, Some(fp_b), None, None, &json!({})).unwrap();
        let (two, r) = add_models(&xml, "R-X", &[("B2".into(), Some(other.clone())), ("A2".into(), None)], &json!({"MPN": "X1"}), None, Some("a resistor")).unwrap();
        assert_eq!((r["models"].as_u64(), r["added"][0].as_str(), r["alreadyAttached"][0].as_str()), (Some(2), Some("B2"), Some("A2")), "{r}");
        assert!(two.contains(r#"<connect gate="G$1" pin="2" pad="2" />"#) && two.contains(r#"pad="2 2@2""#), "{two}");
        let (one, r) = set_models(&two, "r-x", "B2").unwrap();
        assert_eq!((r["removed"].as_u64(), r["footprint"].as_str()), (Some(2), Some("B2")));
        assert_eq!(one.matches("<device ").count(), 1);
        assert!(one.contains(r#"name="MPN" value="X1""#), "params ride on the kept device");
        assert!(set_models(&one, "R-X", "NOPE").is_err());
    }
}

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

    #[test]
    fn a_laid_out_land_pattern_becomes_a_package_that_preflights() {
        let layout = json!({
            "pads": [{"name": "1", "x": -2.7, "y": 0.635, "w": 1.55, "h": 0.6, "shape": "roundrect", "hole": 0},
                     {"name": "2", "x": 2.7, "y": 0.635, "w": 1.55, "h": 0.6, "shape": "rect", "hole": 0},
                     {"name": "3", "x": 0, "y": 0, "w": 1.7, "h": 1.7, "shape": "round", "hole": 0.8}],
            "lines": [{"layer": "overlay", "x1": -1.0, "y1": 2.5, "x2": 1.0, "y2": 2.5, "width": 0.15},
                      {"layer": "fab", "x1": -1.95, "y1": 2.45, "x2": 1.95, "y2": 2.45, "width": 0.1},
                      {"layer": "courtyard", "x1": -3.8, "y1": 2.8, "x2": 3.8, "y2": 2.8, "width": 0.05}],
            "discs": [{"layer": "overlay", "x": -3.9, "y": 0.635, "r": 0.15}]});
        let (xml, s) = create_from_layout(None, "SOIC-TEST", "test", &layout).unwrap();
        assert_eq!((s["pads"].as_u64(), s["silkLines"].as_u64(), s["fabLines"].as_u64(), s["courtyardLines"].as_u64()), (Some(3), Some(1), Some(1), Some(1)));
        assert!(xml.contains(r#"<smd name="1" x="-2.7" y="0.635" dx="1.55" dy="0.6" layer="1" roundness="25" />"#), "{xml}");
        assert!(xml.contains(r#"<smd name="2" x="2.7" y="0.635" dx="1.55" dy="0.6" layer="1" />"#));
        assert!(xml.contains(r#"<pad name="3" x="0" y="0" drill="0.8" diameter="1.7" />"#));
        assert!(xml.contains(r#"<circle x="-3.9" y="0.635" radius="0.15" width="0" layer="21" />"#));
        assert!(xml.contains(r#"layer="39" />"#) && xml.contains(r#"layer="51" />"#));
        let p = std::env::temp_dir().join(format!("adom-layout-test-{}.lbr", std::process::id()));
        std::fs::write(&p, &xml).unwrap();
        let (ok, errs) = preflight_text(&p);
        let _ = std::fs::remove_file(&p);
        assert!(ok, "{errs:?}");
        // the same name twice is refused
        assert!(create_from_layout(Some(&xml), "soic-test", "", &layout).is_err());
        assert!(create_from_layout(None, "X", "", &json!({"pads": [], "lines": []})).is_err());
    }
}

#[cfg(test)]
mod plate_cleanup_tests {
    use super::*;
    const LIB: &str = r#"<?xml version="1.0"?><eagle><drawing><library><packages>
<package name="QFN"><smd name="EP" x="0" y="0" dx="3" dy="3" layer="1"/><smd name="1" x="2" y="0" dx="0.3" dy="0.8" layer="1"/><hole x="0.5" y="0.5" drill="0.3"/><hole x="-0.5" y="-0.5" drill="0.3"/><hole x="5" y="5" drill="1"/></package>
<package name="EMPTY"></package></packages><symbols><symbol name="S"><pin name="GND" x="0" y="0"/><pin name="A" x="0" y="2"/></symbol><symbol name="BLANK"></symbol></symbols>
<devicesets><deviceset name="CHIP"><gates><gate name="G$1" symbol="S" x="0" y="0"/></gates><devices><device name="" package="QFN"><connects><connect gate="G$1" pin="GND" pad="EP"/><connect gate="G$1" pin="A" pad="1"/></connects></device></devices></deviceset>
<deviceset name="Component_1"><gates/><devices/></deviceset></devicesets></library></drawing></eagle>"#;

    #[test]
    fn holes_in_a_land_become_its_plated_vias_on_its_net() {
        let (x, s) = plate_vias(LIB, None).unwrap();
        assert_eq!((s["holes"].as_u64(), s["plated"].as_u64(), s["joinedToLands"].as_u64()), (Some(3), Some(3), Some(2)));
        assert_eq!(s["fixed"], "QFN:EP@2;QFN:EP@3;QFN:H1");
        assert!(x.contains(r#"pad="EP EP@2 EP@3""#) && !x.contains("<hole"));
        assert!(x.contains(r#"<pad name="H1" x="5" y="5" drill="1" />"#));
        assert_eq!(plate_vias(&x, Some("qfn")).unwrap().1["holes"], 0);
    }

    #[test]
    fn cleanup_takes_only_empty_unused_items() {
        let (x, s) = cleanup_libs(LIB).unwrap();
        assert_eq!(s["removed"], 3);
        assert!(x.contains(r#"name="CHIP""#) && !x.contains("Component_1") && !x.contains("BLANK") && !x.contains(r#"name="EMPTY""#));
        assert_eq!(cleanup_libs(&x).unwrap().1["removed"], 0);
    }
}