master
Colby Knox Merge PR #357: kicad-lane: 1.0.90, swap_footprint, plate_vias, normalize_bodies, symbol_mode, explained refusals (#29) e955f3c 11d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001
//! Footprint authoring on `.kicad_mod` files (KiCad 10 format): create one from a pad
//! list (courtyard, fab and silk outlines, reference and value generated), read one back
//! in full, and edit pads, graphics, metadata and 3D models in place. Edits go through the
//! lossless document model in `crate::schematic`, so everything an edit does not touch
//! stays byte-identical; new nodes are laid out the way KiCad's formatter does.

use serde_json::{json, Value};

use crate::pcb::num;
use crate::schematic::{new_uuid, parse_node, pretty, quote, Document, Kind, Node};

pub const FP_VERSION: &str = "20260206";
const GENERATOR: &str = "adom_kicad_bridge";

fn xy(v: Option<&Value>) -> Option<(f64, f64)> {
    match v? {
        Value::Array(a) if a.len() >= 2 => Some((a[0].as_f64()?, a[1].as_f64()?)),
        Value::Object(o) => Some((o.get("x")?.as_f64()?, o.get("y")?.as_f64()?)),
        _ => None,
    }
}

fn xyz(v: Option<&Value>, default: f64) -> Result<[f64; 3], String> {
    match v {
        None | Some(Value::Null) => Ok([default; 3]),
        Some(Value::Array(a)) if a.len() == 3 => {
            let mut out = [0.0; 3];
            for (i, x) in a.iter().enumerate() {
                out[i] = x.as_f64().ok_or("xyz values must be numbers")?;
            }
            Ok(out)
        }
        Some(Value::Object(o)) => Ok([o.get("x").and_then(Value::as_f64).unwrap_or(default), o.get("y").and_then(Value::as_f64).unwrap_or(default), o.get("z").and_then(Value::as_f64).unwrap_or(default)]),
        _ => Err("offset, scale and rotate take [x, y, z] or {x, y, z}".into()),
    }
}

// ---------------------------------------------------------------------------
// Pads
// ---------------------------------------------------------------------------

#[derive(Clone, Debug, PartialEq)]
pub struct PadSpec {
    pub number: String,
    pub kind: String,
    pub shape: String,
    pub at: (f64, f64),
    pub rotation: f64,
    pub size: (f64, f64),
    pub drill: Option<(f64, f64)>,
    pub layers: Vec<String>,
    pub rratio: Option<f64>,
}

pub const PAD_TYPES: &[&str] = &["smd", "thru_hole", "np_thru_hole", "connect"];
pub const PAD_SHAPES: &[&str] = &["circle", "rect", "oval", "roundrect", "trapezoid"];

/// One pad from the verb's JSON: {number, type, shape, size [w,h] | d, drill d | [w,h],
/// at [x,y], rotation, layers, roundrectRatio}. Defaults: smd roundrect 0.25, layers by type.
pub fn pad_from_json(v: &Value, side_back: bool) -> Result<PadSpec, String> {
    let number = match v.get("number") {
        Some(Value::String(s)) => s.clone(),
        Some(Value::Number(n)) => n.to_string(),
        None | Some(Value::Null) => String::new(),
        _ => return Err("pad number must be a string or a number".into()),
    };
    let kind = v.get("type").and_then(Value::as_str).unwrap_or("smd").to_string();
    if !PAD_TYPES.contains(&kind.as_str()) {
        return Err(format!("pad {number}: type must be one of {PAD_TYPES:?}"));
    }
    let shape = v.get("shape").and_then(Value::as_str).unwrap_or(if kind == "smd" { "roundrect" } else { "circle" }).to_string();
    if !PAD_SHAPES.contains(&shape.as_str()) {
        return Err(format!("pad {number}: shape must be one of {PAD_SHAPES:?}"));
    }
    let size = match v.get("size") {
        Some(Value::Number(n)) => {
            let d = n.as_f64().unwrap_or(0.0);
            (d, d)
        }
        other => xy(other).ok_or_else(|| format!("pad {number}: size [w, h] (mm) is required"))?,
    };
    if size.0 <= 0.0 || size.1 <= 0.0 {
        return Err(format!("pad {number}: size must be positive"));
    }
    let drill = match v.get("drill") {
        None | Some(Value::Null) => None,
        Some(Value::Number(n)) => n.as_f64().map(|d| (d, d)),
        other => Some(xy(other).ok_or_else(|| format!("pad {number}: drill is a diameter or [w, h]"))?),
    };
    if kind != "smd" && kind != "connect" && drill.is_none() {
        return Err(format!("pad {number}: a {kind} pad needs a drill"));
    }
    if let Some(d) = drill {
        if d.0 <= 0.0 || d.1 <= 0.0 {
            return Err(format!("pad {number}: drill must be positive"));
        }
        if kind == "thru_hole" && (d.0 >= size.0 || d.1 >= size.1) {
            return Err(format!("pad {number}: the drill ({}) must be smaller than the pad ({} x {})", num(d.0), num(size.0), num(size.1)));
        }
    }
    let at = xy(v.get("at").or_else(|| v.get("position"))).unwrap_or((0.0, 0.0));
    let rotation = v.get("rotation").and_then(Value::as_f64).unwrap_or(0.0);
    let f = if side_back { "B" } else { "F" };
    let layers = match v.get("layers") {
        Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
        _ => match kind.as_str() {
            "smd" => vec![format!("{f}.Cu"), format!("{f}.Mask"), format!("{f}.Paste")],
            "connect" => vec![format!("{f}.Cu"), format!("{f}.Mask")],
            "thru_hole" => vec!["*.Cu".into(), "*.Mask".into()],
            _ => vec!["*.Cu".into(), "*.Mask".into()],
        },
    };
    let rratio = if shape == "roundrect" { Some(v.get("roundrectRatio").and_then(Value::as_f64).unwrap_or(0.25).clamp(0.0, 0.5)) } else { None };
    Ok(PadSpec { number, kind, shape, at, rotation, size, drill, layers, rratio })
}

pub fn pad_text(p: &PadSpec) -> String {
    let rot = if p.rotation != 0.0 { format!(" {}", num(p.rotation)) } else { String::new() };
    let drill = match p.drill {
        Some((a, b)) if (a - b).abs() > 1e-9 => format!("(drill oval {} {})", num(a), num(b)),
        Some((a, _)) => format!("(drill {})", num(a)),
        None => String::new(),
    };
    let layers: Vec<String> = p.layers.iter().map(|l| quote(l)).collect();
    let rr = p.rratio.map(|r| format!("(roundrect_rratio {})", num(r))).unwrap_or_default();
    let extra = if p.kind == "thru_hole" { "(remove_unused_layers no)" } else { "" };
    format!(
        "(pad {} {} {} (at {} {}{rot}) (size {} {}) {drill} (layers {}) {extra} {rr} (uuid \"{}\"))",
        quote(&p.number), p.kind, p.shape, num(p.at.0), num(p.at.1), num(p.size.0), num(p.size.1), layers.join(" "), new_uuid()
    )
}

/// The axis-aligned box of a pad (rotation applied).
fn pad_box(p: &PadSpec) -> (f64, f64, f64, f64) {
    let a = p.rotation.to_radians();
    let (hw, hh) = (p.size.0 / 2.0, p.size.1 / 2.0);
    let ex = (hw * a.cos()).abs() + (hh * a.sin()).abs();
    let ey = (hw * a.sin()).abs() + (hh * a.cos()).abs();
    (p.at.0 - ex, p.at.1 - ey, p.at.0 + ex, p.at.1 + ey)
}

fn union(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)) -> (f64, f64, f64, f64) {
    (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3))
}

fn snap_out(b: (f64, f64, f64, f64), grid: f64) -> (f64, f64, f64, f64) {
    ((b.0 / grid).floor() * grid, (b.1 / grid).floor() * grid, (b.2 / grid).ceil() * grid, (b.3 / grid).ceil() * grid)
}

/// Node from compact text, laid out at depth 1 (a direct child of the footprint).
fn node(text: &str) -> Result<Node, String> {
    let mut n = parse_node(text)?;
    pretty(&mut n, 1);
    Ok(n)
}

// ---------------------------------------------------------------------------
// Graphics
// ---------------------------------------------------------------------------

/// One graphic from JSON: {type: line|arc|rect|circle|poly, start, end, mid, center, points,
/// width, fill}. Returns the fp_* text on `layer`.
pub fn graphic_text(v: &Value, layer: &str, default_width: f64) -> Result<String, String> {
    let t = v.get("type").and_then(Value::as_str).ok_or("each shape needs a type: line, arc, rect, circle or poly")?;
    let w = v.get("width").and_then(Value::as_f64).unwrap_or(default_width);
    let fill = match v.get("fill") {
        Some(Value::Bool(true)) => "yes",
        Some(Value::String(s)) if s == "yes" || s == "solid" => "yes",
        _ => "no",
    };
    let stroke = format!("(stroke (width {}) (type solid))", num(w));
    let p = |k: &str| xy(v.get(k)).ok_or_else(|| format!("{t}: {k} [x, y] is required"));
    let uuid = new_uuid();
    let lay = quote(layer);
    Ok(match t {
        "line" => {
            let (a, b) = (p("start")?, p("end")?);
            format!("(fp_line (start {} {}) (end {} {}) {stroke} (layer {lay}) (uuid \"{uuid}\"))", num(a.0), num(a.1), num(b.0), num(b.1))
        }
        "arc" => {
            let (a, m, b) = (p("start")?, p("mid")?, p("end")?);
            format!("(fp_arc (start {} {}) (mid {} {}) (end {} {}) {stroke} (layer {lay}) (uuid \"{uuid}\"))", num(a.0), num(a.1), num(m.0), num(m.1), num(b.0), num(b.1))
        }
        "rect" => {
            let (a, b) = (p("start")?, p("end")?);
            format!("(fp_rect (start {} {}) (end {} {}) {stroke} (fill {fill}) (layer {lay}) (uuid \"{uuid}\"))", num(a.0), num(a.1), num(b.0), num(b.1))
        }
        "circle" => {
            let c = p("center")?;
            let e = match (xy(v.get("end")), v.get("radius").and_then(Value::as_f64)) {
                (Some(e), _) => e,
                (None, Some(r)) => (c.0 + r, c.1),
                _ => return Err("circle: end [x, y] or radius is required".into()),
            };
            format!("(fp_circle (center {} {}) (end {} {}) {stroke} (fill {fill}) (layer {lay}) (uuid \"{uuid}\"))", num(c.0), num(c.1), num(e.0), num(e.1))
        }
        "poly" => {
            let pts: Vec<(f64, f64)> = v.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(|q| xy(Some(q))).collect()).unwrap_or_default();
            if pts.len() < 3 {
                return Err("poly: points needs at least three [x, y]".into());
            }
            let xys: Vec<String> = pts.iter().map(|(x, y)| format!("(xy {} {})", num(*x), num(*y))).collect();
            format!("(fp_poly (pts {}) {stroke} (fill {fill}) (layer {lay}) (uuid \"{uuid}\"))", xys.join(" "))
        }
        other => return Err(format!("unknown shape type {other}: line, arc, rect, circle or poly")),
    })
}

fn rect_text(b: (f64, f64, f64, f64), layer: &str, width: f64) -> String {
    format!("(fp_rect (start {} {}) (end {} {}) (stroke (width {}) (type solid)) (fill no) (layer {}) (uuid \"{}\"))", num(b.0), num(b.1), num(b.2), num(b.3), num(width), quote(layer), new_uuid())
}

fn property_text(name: &str, value: &str, at: (f64, f64), layer: &str, hide: bool, size: f64) -> String {
    format!(
        "(property {} {} (at {} {} 0) (layer {}){} (uuid \"{}\") (effects (font (size {} {}) (thickness {}))))",
        quote(name), quote(value), num(at.0), num(at.1), quote(layer), if hide { " (hide yes)" } else { "" }, new_uuid(), num(size), num(size), num((size * 0.15 * 100.0).round() / 100.0)
    )
}

fn model_text(m: &Value) -> Result<String, String> {
    let path = m.get("path").and_then(Value::as_str).filter(|p| !p.trim().is_empty()).ok_or("a model needs a path")?;
    let o = xyz(m.get("offset"), 0.0)?;
    let s = xyz(m.get("scale"), 1.0)?;
    let r = xyz(m.get("rotate").or_else(|| m.get("rotation")), 0.0)?;
    let t = |v: [f64; 3]| format!("(xyz {} {} {})", num(v[0]), num(v[1]), num(v[2]));
    Ok(format!("(model {} (offset {}) (scale {}) (rotate {}))", quote(path), t(o), t(s), t(r)))
}

// ---------------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------------

/// A complete .kicad_mod from the verb's args. Returns (text, summary).
pub fn create(name: &str, args: &Value) -> Result<(String, Value), String> {
    if name.trim().is_empty() || name.contains(['/', '\\', ':', '"']) {
        return Err("name must be a plain footprint name (no / \\ : or quotes)".into());
    }
    let back = args.get("side").and_then(Value::as_str) == Some("back");
    let pads_v = args.get("pads").and_then(Value::as_array).ok_or("pads is required: a list of {number, type, shape, size, at, drill?}")?;
    if pads_v.is_empty() {
        return Err("pads is empty".into());
    }
    let pads: Vec<PadSpec> = pads_v.iter().map(|p| pad_from_json(p, back)).collect::<Result<_, _>>()?;
    let pad_bb = pads.iter().map(pad_box).reduce(union).unwrap();
    let body = match xy(args.get("bodySize")) {
        Some((w, h)) => (-w / 2.0, -h / 2.0, w / 2.0, h / 2.0),
        None => pad_bb,
    };
    let margin = args.get("courtyardMargin").and_then(Value::as_f64).unwrap_or(0.25).max(0.0);
    let all = union(pad_bb, body);
    let silk_on = args.get("silk").and_then(Value::as_bool).unwrap_or(true);
    // Silkscreen box: 0.2 mm clear of every pad (never over copper), stroke 0.12 mm.
    let silk_box = union(body, (pad_bb.0 - 0.26, pad_bb.1 - 0.26, pad_bb.2 + 0.26, pad_bb.3 + 0.26));
    let mut crt = (all.0 - margin, all.1 - margin, all.2 + margin, all.3 + margin);
    if silk_on {
        // the courtyard also encloses the silkscreen (and the pin-1 dot left of it)
        crt = union(crt, (silk_box.0 - 0.5, silk_box.1 - 0.12, silk_box.2 + 0.12, silk_box.3 + 0.12));
    }
    let crt = snap_out(crt, 0.01);
    // explicit graphics (footprint_from_land_pattern's layout): they replace the generated silk box,
    // fab rectangle and courtyard; layer is silk | fab | courtyard
    let given = args.get("graphics").and_then(Value::as_array).filter(|g| !g.is_empty());
    let crt = match (given, args.get("courtyardRect").and_then(Value::as_array)) {
        (Some(_), Some(c)) if c.len() == 4 => (c[0].as_f64().unwrap_or(crt.0), c[1].as_f64().unwrap_or(crt.1), c[2].as_f64().unwrap_or(crt.2), c[3].as_f64().unwrap_or(crt.3)),
        _ => crt,
    };
    let f = if back { "B" } else { "F" };
    let smd = pads.iter().all(|p| p.kind == "smd" || p.kind == "connect");
    let attr = match args.get("attr").and_then(Value::as_str) {
        Some(a) if ["smd", "through_hole", "unspecified"].contains(&a) => a.to_string(),
        Some(a) => return Err(format!("attr must be smd, through_hole or unspecified (got {a})")),
        None => if smd { "smd".into() } else { "through_hole".into() },
    };
    let mut parts: Vec<String> = Vec::new();
    parts.push(format!("(version {FP_VERSION})"));
    parts.push(format!("(generator {})", quote(GENERATOR)));
    parts.push("(generator_version \"10.0\")".into());
    parts.push(format!("(layer \"{f}.Cu\")"));
    if let Some(d) = args.get("description").and_then(Value::as_str) {
        parts.push(format!("(descr {})", quote(d)));
    }
    if let Some(t) = args.get("tags") {
        let t = match t {
            Value::Array(a) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "),
            Value::String(s) => s.clone(),
            _ => String::new(),
        };
        if !t.is_empty() {
            parts.push(format!("(tags {})", quote(&t)));
        }
    }
    let text_size = 1.0;
    parts.push(property_text("Reference", args.get("reference").and_then(Value::as_str).unwrap_or("REF**"), (0.0, crt.1 - 0.8), &format!("{f}.SilkS"), false, text_size));
    parts.push(property_text("Value", args.get("value").and_then(Value::as_str).unwrap_or(name), (0.0, crt.3 + 0.8), &format!("{f}.Fab"), false, text_size));
    parts.push(property_text("Datasheet", args.get("datasheet").and_then(Value::as_str).unwrap_or(""), (0.0, 0.0), &format!("{f}.Fab"), true, text_size));
    parts.push(property_text("Description", args.get("description").and_then(Value::as_str).unwrap_or(""), (0.0, 0.0), &format!("{f}.Fab"), true, text_size));
    parts.push(format!("(attr {attr})"));
    parts.push("(duplicate_pad_numbers_are_jumpers no)".into());
    if let Some(g) = given {
        for e in g {
            let layer = match e["layer"].as_str().unwrap_or("silk") {
                "fab" => format!("{f}.Fab"),
                "courtyard" => format!("{f}.CrtYd"),
                _ => format!("{f}.SilkS"),
            };
            let p = |k: &str| -> Result<(f64, f64), String> { xy(e.get(k)).ok_or_else(|| format!("a graphic needs {k}: [x, y]")) };
            if e.get("r").is_some() {
                let (c, r) = (p("center")?, e["r"].as_f64().unwrap_or(0.15));
                parts.push(format!("(fp_circle (center {} {}) (end {} {}) (stroke (width {}) (type solid)) (fill yes) (layer {}) (uuid \"{}\"))", num(c.0), num(c.1), num(c.0 + r / 2.0), num(c.1), num(r), quote(&layer), new_uuid()));
            } else {
                let (a, b) = (p("start")?, p("end")?);
                parts.push(format!("(fp_line (start {} {}) (end {} {}) (stroke (width {}) (type solid)) (layer {}) (uuid \"{}\"))", num(a.0), num(a.1), num(b.0), num(b.1), num(e["width"].as_f64().unwrap_or(0.12)), quote(&layer), new_uuid()));
            }
        }
    }
    // Silkscreen: a box clear of every pad by 0.2 mm (never over copper), and a pin-1 dot.
    if silk_on && given.is_none() {
        let sb = silk_box;
        parts.push(rect_text(sb, &format!("{f}.SilkS"), 0.12));
        if let Some(p1) = pads.iter().find(|p| p.number == "1") {
            let (cx, cy) = (sb.0 - 0.3, p1.at.1.clamp(sb.1, sb.3));
            parts.push(format!("(fp_circle (center {} {}) (end {} {}) (stroke (width 0.12) (type solid)) (fill yes) (layer \"{f}.SilkS\") (uuid \"{}\"))", num(cx), num(cy), num(cx + 0.1), num(cy), new_uuid()));
        }
    }
    if given.is_none() {
        parts.push(rect_text(body, &format!("{f}.Fab"), 0.1));
        parts.push(rect_text(crt, &format!("{f}.CrtYd"), 0.05));
    }
    parts.push(format!("(fp_text user \"${{REFERENCE}}\" (at 0 0 0) (layer \"{f}.Fab\") (uuid \"{}\") (effects (font (size 0.5 0.5) (thickness 0.08))))", new_uuid()));
    for p in &pads {
        parts.push(pad_text(p));
    }
    parts.push("(embedded_fonts no)".into());
    if let Some(m) = args.get("model") {
        if !m.is_null() {
            parts.push(model_text(m)?);
        }
    }
    let mut root = parse_node(&format!("(footprint {} {})", quote(name), parts.join(" ")))?;
    pretty(&mut root, 0);
    root.lead.clear();
    let text = root.to_text() + "\n";
    crate::lint::check_footprint_structure(&text)?;
    let info = info(&text)?;
    Ok((text, json!({"pads": pads.len(), "attr": attr, "courtyard": {"min": [crt.0, crt.1], "max": [crt.2, crt.3]}, "info": info})))
}

// ---------------------------------------------------------------------------
// Info
// ---------------------------------------------------------------------------

fn nums(n: &Node) -> Vec<f64> {
    n.children().iter().skip(1).filter_map(|c| c.value()?.parse().ok()).collect()
}

/// Full details of one footprint file.
pub fn info(text: &str) -> Result<Value, String> {
    let doc = Document::parse(text)?;
    let r = &doc.root;
    if r.head() != Some("footprint") && r.head() != Some("module") {
        return Err(format!("not a footprint (top-level list is {:?})", r.head()));
    }
    let mut props = serde_json::Map::new();
    let mut pads = Vec::new();
    let mut graphics: std::collections::BTreeMap<String, usize> = Default::default();
    let mut layers: std::collections::BTreeSet<String> = Default::default();
    let mut crt: Option<(f64, f64, f64, f64)> = None;
    let mut models = Vec::new();
    let grow = |b: &mut Option<(f64, f64, f64, f64)>, x: f64, y: f64| {
        *b = Some(match *b {
            None => (x, y, x, y),
            Some(o) => (o.0.min(x), o.1.min(y), o.2.max(x), o.3.max(y)),
        });
    };
    for c in r.children() {
        match c.head() {
            Some("property") => {
                if let (Some(k), Some(v)) = (c.arg(0), c.arg(1)) {
                    props.insert(k, json!(v));
                }
            }
            Some("pad") => {
                let at = c.child("at").map(nums).unwrap_or_default();
                let size = c.child("size").map(nums).unwrap_or_default();
                let drill = c.child("drill").map(nums);
                let pl: Vec<String> = c.child("layers").map(|l| l.children().iter().skip(1).filter_map(Node::value).collect()).unwrap_or_default();
                layers.extend(pl.iter().cloned());
                pads.push(json!({
                    "number": c.arg(0), "type": c.arg(1), "shape": c.arg(2),
                    "at": at.get(..2), "rotation": at.get(2).copied().unwrap_or(0.0),
                    "size": size, "drill": drill, "layers": pl,
                    "roundrectRatio": c.child("roundrect_rratio").and_then(|n| n.num(0)),
                }));
            }
            Some(h) if h.starts_with("fp_") => {
                let layer = c.child("layer").and_then(|l| l.arg(0)).unwrap_or_default();
                layers.insert(layer.clone());
                *graphics.entry(format!("{layer} {}", &h[3..])).or_default() += 1;
                if layer.ends_with("CrtYd") {
                    for k in ["start", "end", "mid", "center"] {
                        if let Some(p) = c.child(k) {
                            if let (Some(x), Some(y)) = (p.num(0), p.num(1)) {
                                grow(&mut crt, x, y);
                            }
                        }
                    }
                    if h == "fp_circle" {
                        if let (Some(cn), Some(e)) = (c.child("center"), c.child("end")) {
                            let (cx, cy) = (cn.num(0).unwrap_or(0.0), cn.num(1).unwrap_or(0.0));
                            let rr = ((e.num(0).unwrap_or(0.0) - cx).powi(2) + (e.num(1).unwrap_or(0.0) - cy).powi(2)).sqrt();
                            grow(&mut crt, cx - rr, cy - rr);
                            grow(&mut crt, cx + rr, cy + rr);
                        }
                    }
                    if let Some(pts) = c.child("pts") {
                        for q in pts.children().iter().filter(|q| q.head() == Some("xy")) {
                            if let (Some(x), Some(y)) = (q.num(0), q.num(1)) {
                                grow(&mut crt, x, y);
                            }
                        }
                    }
                }
            }
            Some("model") => {
                let t = |k: &str| c.child(k).and_then(|o| o.child("xyz")).map(nums);
                models.push(json!({"path": c.arg(0), "offset": t("offset"), "scale": t("scale"), "rotate": t("rotate"), "hidden": c.child("hide").is_some()}));
            }
            _ => {}
        }
    }
    let attrs: Vec<String> = r.child("attr").map(|a| a.children().iter().skip(1).filter_map(Node::value).collect()).unwrap_or_default();
    Ok(json!({
        "name": r.arg(0),
        "version": r.child_value("version"),
        "layer": r.child_value("layer"),
        "description": r.child_value("descr"),
        "tags": r.child_value("tags"),
        "attributes": attrs,
        "properties": props,
        "padCount": pads.len(),
        "pads": pads,
        "layers": layers.into_iter().filter(|l| !l.is_empty()).collect::<Vec<_>>(),
        "graphics": graphics,
        "courtyard": crt.map(|b| json!({"min": [b.0, b.1], "max": [b.2, b.3], "widthMm": ((b.2 - b.0) * 1e6).round() / 1e6, "heightMm": ((b.3 - b.1) * 1e6).round() / 1e6})),
        "models": models,
    }))
}

/// The standard's plate_vias on one footprint: every drilled pad becomes a plated hole through every copper layer
/// (`thru_hole`, layers `*.Cu` `*.Mask`) when it is not one already: a `thru_hole` pad on only some copper layers, or an
/// `smd` pad with a drill. A numbered unplated hole (`np_thru_hole`) is plated too when `include_npth`; otherwise it is
/// listed in `unplated` (a mounting hole is meant to be unplated). Returns the new text, the pads fixed (with what each
/// was), the holes seen and the unplated numbered holes left alone.
pub fn plate_vias(text: &str, include_npth: bool) -> Result<(String, Vec<Value>, usize, Vec<String>), String> {
    let mut doc = Document::parse(text)?;
    let mut fixed = Vec::new();
    let mut unplated = Vec::new();
    let mut holes = 0;
    for c in doc.root.children_mut().into_iter().flatten().filter(|c| c.head() == Some("pad")) {
        if c.child("drill").is_none() {
            continue;
        }
        holes += 1;
        let num = c.arg(0).unwrap_or_default();
        let kind = c.arg(1).unwrap_or_default();
        let layers: Vec<String> = c.child("layers").map(|l| l.children().iter().skip(1).filter_map(Node::value).collect()).unwrap_or_default();
        let through = layers.iter().any(|l| l == "*.Cu") || (layers.iter().any(|l| l == "F.Cu") && layers.iter().any(|l| l == "B.Cu"));
        let fix = match kind.as_str() {
            "thru_hole" => !through,
            "smd" | "connect" => true,
            "np_thru_hole" => {
                if num.is_empty() {
                    false
                } else if include_npth {
                    true
                } else {
                    unplated.push(num.clone());
                    false
                }
            }
            _ => false,
        };
        if !fix {
            continue;
        }
        c.set_arg(1, Node::atom("thru_hole"));
        let mut l = Node::list("layers", vec![Node::quoted("*.Cu"), Node::quoted("*.Mask")]);
        if let Some(old) = c.child_mut("layers") {
            l.lead = old.lead.clone();
            *old = l;
        }
        fixed.push(json!({"pad": num, "was": kind, "layers": layers}));
    }
    Ok((doc.to_text(), fixed, holes, unplated))
}

/// The standard's normalize_bodies on one footprint: a 3D model whose body sits more than `min_shift` mm from the
/// footprint's pad cluster (centre to centre, in the plane) is moved onto it by its offset. `resolve` gives a model
/// file's bounds. Returns the new text, the models moved ({index, path, shiftMm, offsetBefore, offsetAfter}) and the
/// models left as they are (with why).
pub fn normalize_bodies(text: &str, min_shift: f64, resolve: &mut dyn FnMut(&str) -> Result<crate::bodies::Bounds, String>) -> Result<(String, Vec<Value>, Vec<Value>), String> {
    let inf = info(text)?;
    let mut pb: Option<(f64, f64, f64, f64)> = None;
    for p in inf["pads"].as_array().into_iter().flatten() {
        let (x, y) = (p["at"][0].as_f64().unwrap_or(0.0), p["at"][1].as_f64().unwrap_or(0.0));
        let (w, h) = (p["size"][0].as_f64().unwrap_or(0.0) / 2.0, p["size"][1].as_f64().unwrap_or(0.0) / 2.0);
        let e = (x - w, y - h, x + w, y + h);
        pb = Some(pb.map_or(e, |o| (o.0.min(e.0), o.1.min(e.1), o.2.max(e.2), o.3.max(e.3))));
    }
    let Some(pb) = pb else { return Ok((text.to_string(), vec![], vec![json!({"why": "the footprint has no pads to centre on"})])) };
    // the 3D frame is y up; the footprint file's y is down
    let (pcx, pcy) = ((pb.0 + pb.2) / 2.0, -(pb.1 + pb.3) / 2.0);
    let sx = crate::model_check::parse_sexpr(text).ok_or("empty footprint text")?;
    let refs = crate::bodies::model_refs(&sx);
    let mut doc = Document::parse(text)?;
    let (mut moved, mut kept) = (Vec::new(), Vec::new());
    let r4 = |v: f64| (v * 1e4).round() / 1e4 + 0.0;
    let mut model_nodes: Vec<&mut Node> = doc.root.children_mut().into_iter().flatten().filter(|c| c.head() == Some("model")).collect();
    for (i, m) in refs.iter().enumerate() {
        if m.hidden {
            kept.push(json!({"index": i, "path": m.path, "why": "hidden"}));
            continue;
        }
        let b = match resolve(&m.path) {
            Ok(b) => b,
            Err(e) => {
                kept.push(json!({"index": i, "path": m.path, "why": e}));
                continue;
            }
        };
        let local = crate::bodies::in_footprint(&b, m);
        let (bcx, bcy) = ((local.min[0] + local.max[0]) / 2.0, (local.min[1] + local.max[1]) / 2.0);
        let (dx, dy) = (pcx - bcx, pcy - bcy);
        let dist = dx.hypot(dy);
        if dist <= min_shift {
            kept.push(json!({"index": i, "path": m.path, "why": format!("its body is {:.3} mm from the pads' centre (within {min_shift} mm)", dist)}));
            continue;
        }
        let after = [r4(m.offset[0] + dx), r4(m.offset[1] + dy), m.offset[2]];
        let Some(node) = model_nodes.get_mut(i) else { continue };
        let mut off = Node::list("offset", vec![Node::list("xyz", vec![Node::number(after[0]), Node::number(after[1]), Node::number(after[2])])]);
        pretty(&mut off, 2);
        match node.child_mut("offset") {
            Some(o) => {
                *o = off;
            }
            None => {
                if let Some(c) = node.children_mut() {
                    c.insert(1.min(c.len()) + 1, off);
                }
            }
        }
        moved.push(json!({"index": i, "path": m.path, "shiftMm": r4(dist), "offsetBefore": m.offset, "offsetAfter": after}));
    }
    Ok((doc.to_text(), moved, kept))
}

// ---------------------------------------------------------------------------
// Edit
// ---------------------------------------------------------------------------

fn kids(root: &mut Node) -> &mut Vec<Node> {
    root.children_mut().expect("root is a list")
}

/// Index after the last child whose head is in `after`; else before the first in `before`;
/// else before the closing paren.
fn insert_at(root: &Node, after: &[&str], before: &[&str]) -> usize {
    let c = root.children();
    if let Some(i) = c.iter().rposition(|n| n.head().map(|h| after.contains(&h)).unwrap_or(false)) {
        return i + 1;
    }
    if let Some(i) = c.iter().position(|n| n.head().map(|h| before.contains(&h)).unwrap_or(false)) {
        return i;
    }
    c.len()
}

const GRAPHIC_HEADS: &[&str] = &["fp_line", "fp_arc", "fp_rect", "fp_circle", "fp_poly", "fp_curve"];

fn set_child(root: &mut Node, head: &str, text: &str, after: &[&str]) -> Result<(), String> {
    let new = node(text)?;
    if let Some(i) = root.children().iter().position(|c| c.head() == Some(head)) {
        let lead = root.children()[i].lead.clone();
        kids(root)[i] = new;
        kids(root)[i].lead = lead;
    } else {
        let i = insert_at(root, after, &["property", "attr", "fp_line", "pad"]);
        kids(root).insert(i, new);
    }
    Ok(())
}

/// Apply the edit operations in `args` to footprint text. Returns (new text, changes).
/// Operations: pads (edit, delete, add), renumber, graphics, metadata, models.
pub fn edit(text: &str, args: &Value) -> Result<(String, Vec<String>), String> {
    let mut doc = Document::parse(text)?;
    if doc.root.head() != Some("footprint") {
        return Err("not a KiCad 6+ footprint (the top-level list must be footprint)".into());
    }
    let mut changes: Vec<String> = Vec::new();
    let back = doc.root.child_value("layer").as_deref() == Some("B.Cu");

    // pads: [{number, delete?, set?:{...}}], addPads: [padSpec]
    if let Some(list) = args.get("pads").and_then(Value::as_array) {
        for op in list {
            let want = match op.get("number") {
                Some(Value::String(s)) => s.clone(),
                Some(Value::Number(n)) => n.to_string(),
                _ => return Err("each pads entry needs the number of the pad it edits".into()),
            };
            let idxs: Vec<usize> = doc.root.children().iter().enumerate().filter(|(_, c)| c.head() == Some("pad") && c.arg(0).as_deref() == Some(want.as_str())).map(|(i, _)| i).collect();
            if idxs.is_empty() {
                return Err(format!("no pad numbered {want}"));
            }
            if op.get("delete").and_then(Value::as_bool).unwrap_or(false) {
                for i in idxs.iter().rev() {
                    kids(&mut doc.root).remove(*i);
                }
                changes.push(format!("deleted pad {want} ({} pad(s))", idxs.len()));
                continue;
            }
            let set = op.get("set").unwrap_or(op);
            for &i in &idxs {
                let pad = &mut kids(&mut doc.root)[i];
                let mut cur = pad_from_node(pad)?;
                if let Some(v) = set.get("newNumber").or_else(|| set.get("renumber")) {
                    cur.number = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
                }
                let merged = merge_pad(&cur, set, back)?;
                let mut fresh = node(&pad_text(&merged))?;
                // keep the pad's uuid and any child the bridge does not model (net, pinfunction, ...)
                carry_unmodelled(pad, &mut fresh);
                fresh.lead = pad.lead.clone();
                *pad = fresh;
            }
            changes.push(format!("edited pad {want}"));
        }
    }
    if let Some(list) = args.get("addPads").and_then(Value::as_array) {
        for p in list {
            let spec = pad_from_json(p, back)?;
            let i = insert_at(&doc.root, &["pad"], &["embedded_fonts", "model", "zone", "group"]);
            kids(&mut doc.root).insert(i, node(&pad_text(&spec))?);
            changes.push(format!("added pad {}", spec.number));
        }
    }
    // renumber: {"map": {"1": "A1"}} or {"sequential": true, "start": 1}
    if let Some(r) = args.get("renumber") {
        let mut n = r.get("start").and_then(Value::as_u64).unwrap_or(1);
        let map = r.get("map").and_then(Value::as_object).cloned().unwrap_or_default();
        let seq = r.get("sequential").and_then(Value::as_bool).unwrap_or(false);
        if map.is_empty() && !seq {
            return Err("renumber needs map {old: new} or sequential:true".into());
        }
        let mut count = 0;
        for c in kids(&mut doc.root).iter_mut().filter(|c| c.head() == Some("pad")) {
            let old = c.arg(0).unwrap_or_default();
            if old.is_empty() && seq {
                continue; // unnumbered mechanical pads keep no number
            }
            let new = if seq {
                let v = n.to_string();
                n += 1;
                Some(v)
            } else {
                map.get(&old).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))
            };
            if let Some(v) = new {
                c.set_arg(0, Node::quoted(&v));
                count += 1;
            }
        }
        changes.push(format!("renumbered {count} pad(s)"));
    }
    // graphics: {layer, mode: append|replace|delete, shapes: [...], width?}
    if let Some(gs) = args.get("graphics") {
        let ops: Vec<&Value> = match gs {
            Value::Array(a) => a.iter().collect(),
            v => vec![v],
        };
        for g in ops {
            let layer = g.get("layer").and_then(Value::as_str).ok_or("graphics needs a layer (F.SilkS, F.Fab, F.CrtYd, ...)")?;
            let mode = g.get("mode").and_then(Value::as_str).unwrap_or("append");
            if !["append", "replace", "delete"].contains(&mode) {
                return Err("graphics mode is append, replace or delete".into());
            }
            if mode != "append" {
                let before = doc.root.children().len();
                kids(&mut doc.root).retain(|c| !(c.head().map(|h| GRAPHIC_HEADS.contains(&h)).unwrap_or(false) && c.child("layer").and_then(|l| l.arg(0)).as_deref() == Some(layer)));
                changes.push(format!("removed {} graphic(s) on {layer}", before - doc.root.children().len()));
            }
            if mode != "delete" {
                let default_w = if layer.ends_with("CrtYd") { 0.05 } else if layer.ends_with("SilkS") || layer.ends_with("Silkscreen") { 0.12 } else { 0.1 };
                let shapes = g.get("shapes").and_then(Value::as_array).ok_or("graphics needs shapes")?;
                for s in shapes {
                    let t = graphic_text(s, layer, g.get("width").and_then(Value::as_f64).unwrap_or(default_w))?;
                    let i = insert_at(&doc.root, GRAPHIC_HEADS, &["fp_text", "fp_text_box", "pad", "embedded_fonts", "model"]);
                    kids(&mut doc.root).insert(i, node(&t)?);
                }
                changes.push(format!("added {} shape(s) on {layer}", shapes.len()));
            }
        }
    }
    // metadata: {description, tags, attributes: {type, boardOnly, excludeFromPos, excludeFromBom, dnp} | [..]}
    if let Some(m) = args.get("metadata") {
        if let Some(d) = m.get("description").and_then(Value::as_str) {
            set_child(&mut doc.root, "descr", &format!("(descr {})", quote(d)), &["layer", "uuid", "at"])?;
            // KiCad 8+ also keeps the description as a property.
            if let Some(p) = kids(&mut doc.root).iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some("Description")) {
                p.set_arg(1, Node::quoted(d));
            }
            changes.push("description".into());
        }
        if let Some(t) = m.get("tags") {
            let t = match t {
                Value::Array(a) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "),
                Value::String(s) => s.clone(),
                _ => String::new(),
            };
            set_child(&mut doc.root, "tags", &format!("(tags {})", quote(&t)), &["descr", "layer"])?;
            changes.push("tags".into());
        }
        if let Some(a) = m.get("attributes") {
            let atoms: Vec<String> = match a {
                Value::Array(list) => list.iter().filter_map(Value::as_str).map(str::to_string).collect(),
                Value::Object(o) => {
                    let mut v = Vec::new();
                    let ty = o.get("type").and_then(Value::as_str).unwrap_or("smd");
                    if ty != "unspecified" {
                        v.push(ty.to_string());
                    }
                    for (k, atom) in [("boardOnly", "board_only"), ("excludeFromPos", "exclude_from_pos_files"), ("excludeFromBom", "exclude_from_bom"), ("allowMissingCourtyard", "allow_missing_courtyard"), ("dnp", "dnp")] {
                        if o.get(k).and_then(Value::as_bool).unwrap_or(false) {
                            v.push(atom.into());
                        }
                    }
                    v
                }
                _ => return Err("attributes is a list of KiCad attr atoms or {type, boardOnly, excludeFromPos, excludeFromBom, dnp}".into()),
            };
            const OK: &[&str] = &["smd", "through_hole", "board_only", "exclude_from_pos_files", "exclude_from_bom", "allow_missing_courtyard", "dnp", "allow_soldermask_bridges"];
            if let Some(bad) = atoms.iter().find(|a| !OK.contains(&a.as_str())) {
                return Err(format!("unknown attribute {bad}; known: {OK:?}"));
            }
            if atoms.is_empty() {
                kids(&mut doc.root).retain(|c| c.head() != Some("attr"));
            } else {
                set_child(&mut doc.root, "attr", &format!("(attr {})", atoms.join(" ")), &["property"])?;
            }
            changes.push(format!("attributes [{}]", atoms.join(" ")));
        }
    }
    // models: [{path, offset, scale, rotate}] with mode replace (default) | append | delete
    if let Some(ms) = args.get("models") {
        let (mode, list): (&str, Vec<Value>) = match ms {
            Value::Array(a) => ("replace", a.clone()),
            Value::Object(o) => (o.get("mode").and_then(Value::as_str).unwrap_or("replace"), o.get("list").and_then(Value::as_array).cloned().unwrap_or_default()),
            _ => return Err("models is a list of {path, offset, scale, rotate} or {mode, list}".into()),
        };
        if !["replace", "append", "delete"].contains(&mode) {
            return Err("models mode is replace, append or delete".into());
        }
        if mode != "append" {
            kids(&mut doc.root).retain(|c| c.head() != Some("model"));
            changes.push("removed existing 3D models".into());
        }
        if mode != "delete" {
            for m in &list {
                let t = model_text(m)?;
                let i = doc.root.children().len();
                kids(&mut doc.root).insert(i, node(&t)?);
            }
            changes.push(format!("added {} 3D model(s)", list.len()));
        }
    }
    if changes.is_empty() {
        return Err("nothing to do: pass pads, addPads, renumber, graphics, metadata or models".into());
    }
    let out = doc.to_text();
    crate::lint::check_footprint_structure(&out)?;
    Document::parse(&out)?;
    Ok((out, changes))
}

/// Read a pad node back into a spec.
fn pad_from_node(n: &Node) -> Result<PadSpec, String> {
    let at = n.child("at").map(nums).unwrap_or_default();
    let size = n.child("size").map(nums).unwrap_or_default();
    let drill = n.child("drill").map(nums).filter(|d| !d.is_empty()).map(|d| (d[0], *d.get(1).unwrap_or(&d[0])));
    Ok(PadSpec {
        number: n.arg(0).unwrap_or_default(),
        kind: n.arg(1).unwrap_or_else(|| "smd".into()),
        shape: n.arg(2).unwrap_or_else(|| "rect".into()),
        at: (*at.first().unwrap_or(&0.0), *at.get(1).unwrap_or(&0.0)),
        rotation: *at.get(2).unwrap_or(&0.0),
        size: (*size.first().unwrap_or(&1.0), *size.get(1).unwrap_or(size.first().unwrap_or(&1.0))),
        drill,
        layers: n.child("layers").map(|l| l.children().iter().skip(1).filter_map(Node::value).collect()).unwrap_or_default(),
        rratio: n.child("roundrect_rratio").and_then(|r| r.num(0)),
    })
}

fn merge_pad(cur: &PadSpec, set: &Value, back: bool) -> Result<PadSpec, String> {
    let mut j = json!({
        "number": cur.number, "type": cur.kind, "shape": cur.shape, "at": [cur.at.0, cur.at.1], "rotation": cur.rotation,
        "size": [cur.size.0, cur.size.1], "layers": cur.layers,
    });
    if let Some(d) = cur.drill {
        j["drill"] = json!([d.0, d.1]);
    }
    if let Some(r) = cur.rratio {
        j["roundrectRatio"] = json!(r);
    }
    for k in ["type", "shape", "at", "position", "rotation", "size", "drill", "layers", "roundrectRatio"] {
        if let Some(v) = set.get(k) {
            j[if k == "position" { "at" } else { k }] = v.clone();
        }
    }
    if set.get("type").and_then(Value::as_str) == Some("smd") && set.get("drill").is_none() {
        j.as_object_mut().unwrap().remove("drill");
        if set.get("layers").is_none() {
            j.as_object_mut().unwrap().remove("layers");
        }
    }
    pad_from_json(&j, back)
}

/// Copy children of `old` that `pad_text` does not write (net, pinfunction, pintype,
/// thermal settings, clearances...) and the original uuid into `new`.
fn carry_unmodelled(old: &Node, new: &mut Node) {
    const MODELLED: &[&str] = &["at", "size", "drill", "layers", "roundrect_rratio", "remove_unused_layers", "uuid"];
    let keep: Vec<Node> = old.children().iter().skip(4).filter(|c| c.is_list() && !c.head().map(|h| MODELLED.contains(&h)).unwrap_or(false)).cloned().collect();
    let old_uuid = old.child("uuid").cloned();
    if let Kind::List { children, .. } = &mut new.kind {
        if let Some(u) = old_uuid {
            if let Some(slot) = children.iter_mut().find(|c| c.head() == Some("uuid")) {
                let lead = slot.lead.clone();
                *slot = u;
                slot.lead = lead;
            }
        }
        let at = children.iter().position(|c| c.head() == Some("uuid")).unwrap_or(children.len());
        for (k, c) in keep.into_iter().enumerate() {
            children.insert(at + k, c);
        }
    }
}

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

    fn soic() -> Value {
        let mut pads = Vec::new();
        for i in 0..4 {
            pads.push(json!({"number": i + 1, "size": [1.5, 0.6], "at": [-2.7, -1.905 + 1.27 * i as f64]}));
            pads.push(json!({"number": 8 - i, "size": [1.5, 0.6], "at": [2.7, -1.905 + 1.27 * i as f64]}));
        }
        json!({"pads": pads, "bodySize": [3.9, 4.9], "description": "SOIC-8 test", "tags": ["soic", "so8"], "model": {"path": "${KICAD10_3DMODEL_DIR}/Package_SO.3dshapes/SOIC-8.step"}})
    }

    #[test]
    fn create_writes_a_parseable_kicad10_footprint_with_courtyard() {
        let (text, summary) = create("SOIC8_Test", &soic()).unwrap();
        assert!(text.starts_with("(footprint \"SOIC8_Test\"\n\t(version 20260206)"));
        crate::lint::check_footprint_structure(&text).unwrap();
        let lint = crate::lint::parse_kicad_mod(&text);
        assert_eq!(lint["padCount"], 8);
        assert_eq!(lint["fileVersion"], 20260206);
        let i = info(&text).unwrap();
        assert_eq!(i["attributes"], json!(["smd"]));
        assert_eq!(i["pads"][0]["shape"], "roundrect");
        assert_eq!(i["pads"][0]["roundrectRatio"], 0.25);
        // courtyard: pads reach x 3.45 (silk box 3.71 plus stroke), body y 2.45 plus 0.25 margin
        let c = &summary["courtyard"];
        assert_eq!(c["max"][0], 3.83);
        assert_eq!(c["max"][1], 2.7);
        assert_eq!(c["min"][0], -4.21, "the pin-1 dot sits left of the silk box");
        assert_eq!(i["models"][0]["scale"], json!([1.0, 1.0, 1.0]));
        assert!(i["layers"].as_array().unwrap().iter().any(|l| l == "F.CrtYd"));
        assert!(i["layers"].as_array().unwrap().iter().any(|l| l == "F.SilkS"));
        assert_eq!(i["tags"], "soic so8");
        // the generated silkscreen never lands on a pad
        let board = format!("(kicad_pcb (version 20260206) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)) {})", text.replacen("(footprint \"SOIC8_Test\"", "(footprint \"L:SOIC8_Test\" (at 10 10) (property \"Reference\" \"U1\")", 1));
        let g = crate::dfm::Geometry::from_text(&board).unwrap();
        assert_eq!(g.pads.len(), 8);
        assert!(g.silk_on_pads().is_empty(), "{:?}", g.silk_on_pads());
    }

    #[test]
    fn create_rejects_bad_pads() {
        assert!(create("X", &json!({"pads": []})).is_err());
        assert!(create("X", &json!({"pads": [{"number": 1, "type": "thru_hole", "size": [1, 1]}]})).unwrap_err().contains("needs a drill"));
        assert!(create("X", &json!({"pads": [{"number": 1, "type": "thru_hole", "size": [1, 1], "drill": 1.2}]})).unwrap_err().contains("smaller"));
        assert!(create("X", &json!({"pads": [{"number": 1, "shape": "hex", "size": [1, 1]}]})).is_err());
        assert!(create("a/b", &soic()).is_err());
        let (tht, _) = create("Pin", &json!({"pads": [{"number": 1, "type": "thru_hole", "shape": "rect", "size": 1.7, "drill": 1.0}, {"number": 2, "type": "thru_hole", "size": 1.7, "drill": 1.0, "at": [2.54, 0]}, {"type": "np_thru_hole", "size": 3.2, "drill": 3.2, "at": [6, 0]}]})).unwrap();
        let i = info(&tht).unwrap();
        assert_eq!(i["attributes"], json!(["through_hole"]));
        assert_eq!(i["pads"][1]["layers"], json!(["*.Cu", "*.Mask"]));
        assert_eq!(i["pads"][2]["number"], "");
    }

    #[test]
    fn edit_pads_graphics_metadata_models_and_keeps_the_rest() {
        let (text, _) = create("SOIC8_Test", &soic()).unwrap();
        // a pad with a child the bridge does not model must keep it
        let text = text.replacen("(layers \"F.Cu\" \"F.Mask\" \"F.Paste\")\n\t\t(roundrect_rratio 0.25)", "(layers \"F.Cu\" \"F.Mask\" \"F.Paste\")\n\t\t(roundrect_rratio 0.25)\n\t\t(solder_mask_margin 0.05)", 1);
        let pad1_uuid = info(&text).unwrap();
        let _ = pad1_uuid;
        let args = json!({
            "pads": [{"number": "1", "set": {"size": [1.6, 0.65], "shape": "rect"}}, {"number": 8, "delete": true}],
            "addPads": [{"number": "EP", "size": [2.2, 3.0]}],
            "graphics": [{"layer": "F.SilkS", "mode": "replace", "shapes": [{"type": "line", "start": [-1, -3], "end": [1, -3]}, {"type": "circle", "center": [-3.5, -2], "radius": 0.1, "fill": true}]},
                         {"layer": "F.Fab", "shapes": [{"type": "poly", "points": [[0, 0], [1, 0], [0, 1]]}, {"type": "arc", "start": [0, 0], "mid": [0.5, 0.5], "end": [1, 0]}]}],
            "metadata": {"description": "edited", "tags": "a b", "attributes": {"type": "smd", "excludeFromBom": true}},
            "models": {"mode": "replace", "list": [{"path": "C:/m/x.step", "offset": [0, 0, 0.1], "rotate": {"z": 90}}]}
        });
        let (out, changes) = edit(&text, &args).unwrap();
        assert!(changes.len() >= 7, "{changes:?}");
        let i = info(&out).unwrap();
        assert_eq!(i["padCount"], 8, "8 - 1 + EP");
        let p1 = i["pads"].as_array().unwrap().iter().find(|p| p["number"] == "1").unwrap();
        assert_eq!(p1["size"], json!([1.6, 0.65]));
        assert_eq!(p1["shape"], "rect");
        assert!(out.contains("(solder_mask_margin 0.05)"), "unmodelled pad child kept");
        assert_eq!(i["description"], "edited");
        assert_eq!(i["attributes"], json!(["smd", "exclude_from_bom"]));
        assert_eq!(i["graphics"]["F.SilkS line"], 1);
        assert_eq!(i["graphics"]["F.SilkS circle"], 1);
        assert!(i["graphics"].get("F.SilkS rect").is_none(), "replace removed the old silk box");
        assert_eq!(i["graphics"]["F.Fab poly"], 1);
        assert_eq!(i["models"].as_array().unwrap().len(), 1);
        assert_eq!(i["models"][0]["rotate"], json!([0.0, 0.0, 90.0]));
        // the courtyard, the reference and the value were not touched
        let before = info(&text).unwrap();
        assert_eq!(before["courtyard"], i["courtyard"]);
        assert_eq!(before["properties"]["Reference"], i["properties"]["Reference"]);
        // renumber
        let (out2, _) = edit(&out, &json!({"renumber": {"map": {"EP": "9"}}})).unwrap();
        assert!(info(&out2).unwrap()["pads"].as_array().unwrap().iter().any(|p| p["number"] == "9"));
        let (out3, _) = edit(&out2, &json!({"renumber": {"sequential": true}})).unwrap();
        let numbers: Vec<String> = info(&out3).unwrap()["pads"].as_array().unwrap().iter().map(|p| p["number"].as_str().unwrap().to_string()).collect();
        assert_eq!(numbers, (1..=8).map(|n| n.to_string()).collect::<Vec<_>>());
        // a no-op edit is refused, an unknown pad is named
        assert!(edit(&text, &json!({})).is_err());
        assert!(edit(&text, &json!({"pads": [{"number": "99", "delete": true}]})).unwrap_err().contains("99"));
        // an untouched region is byte-identical
        let head_end = text.find("(attr").unwrap();
        assert_eq!(&out3[..30], &text[..30]);
        assert!(out3.contains(&text[..head_end].lines().take(4).collect::<Vec<_>>().join("\n")));
    }

    #[test]
    fn info_reads_a_stock_style_footprint() {
        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../pcm/src/adom-sample-lib/footprints/Adom_Sample.pretty/AdomSample_SOIC-8_3.9x4.9mm_P1.27mm.kicad_mod");
        let i = info(&std::fs::read_to_string(p).unwrap()).unwrap();
        assert_eq!(i["padCount"], 8);
        assert_eq!(i["attributes"], json!(["smd"]));
        assert!(i["courtyard"].is_object());
        assert_eq!(i["models"].as_array().unwrap().len(), 1);
        assert!(info("(kicad_symbol_lib)").is_err());
    }

    #[test]
    fn plate_vias_plates_drilled_pads_through_every_copper_layer() {
        let fp = "(footprint \"QFN_vias\"\n\t(layer \"F.Cu\")\n\t(pad \"17\" smd rect (at 0 0) (size 3 3) (layers \"F.Cu\" \"F.Paste\" \"F.Mask\"))\n\t(pad \"17\" thru_hole circle (at 0.5 0.5) (size 0.6 0.6) (drill 0.3) (layers \"F.Cu\"))\n\t(pad \"17\" smd circle (at -0.5 0.5) (size 0.6 0.6) (drill 0.3) (layers \"F.Cu\" \"F.Mask\"))\n\t(pad \"1\" thru_hole circle (at 2 2) (size 1.6 1.6) (drill 0.8) (layers \"*.Cu\" \"*.Mask\"))\n\t(pad \"\" np_thru_hole circle (at 3 3) (size 3 3) (drill 3) (layers \"*.Cu\" \"*.Mask\"))\n\t(pad \"5\" np_thru_hole circle (at 4 4) (size 1 1) (drill 1) (layers \"*.Cu\" \"*.Mask\"))\n)\n";
        let (t, fixed, holes, unplated) = plate_vias(fp, false).unwrap();
        assert_eq!(holes, 5);
        assert_eq!(fixed.len(), 2, "{fixed:?}");
        assert_eq!(fixed[0]["was"], "thru_hole");
        assert_eq!(fixed[1]["was"], "smd");
        assert_eq!(unplated, vec!["5".to_string()]);
        let info = info(&t).unwrap();
        let vias: Vec<&Value> = info["pads"].as_array().unwrap().iter().filter(|p| p["drill"].is_array() && p["number"] == "17").collect();
        assert!(vias.iter().all(|p| p["type"] == "thru_hole" && p["layers"] == json!(["*.Cu", "*.Mask"])), "{vias:?}");
        let (_, fixed2, _, unplated2) = plate_vias(fp, true).unwrap();
        assert_eq!((fixed2.len(), unplated2.len()), (3, 0));
        let (_, again, _, _) = plate_vias(&t, false).unwrap();
        assert!(again.is_empty(), "plating is idempotent");
    }

    #[test]
    fn normalize_bodies_moves_a_far_body_onto_the_pads_and_leaves_a_close_one() {
        // a 2-pad part centred at (1, 0) mm; its model's offset puts the body 50 mm away
        let fp = "(footprint \"R_test\"\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\"))\n\t(pad \"2\" smd rect (at 2 0) (size 1 1) (layers \"F.Cu\"))\n\t(model \"far.step\"\n\t\t(offset\n\t\t\t(xyz 51 0 0)\n\t\t)\n\t\t(scale\n\t\t\t(xyz 1 1 1)\n\t\t)\n\t\t(rotate\n\t\t\t(xyz 0 0 0)\n\t\t)\n\t)\n\t(model \"near.step\"\n\t\t(offset\n\t\t\t(xyz 1.5 0 0)\n\t\t)\n\t)\n)\n";
        let b = crate::bodies::Bounds { min: [-1.0, -0.5, 0.0], max: [1.0, 0.5, 0.5] };
        let mut resolve = |_: &str| -> Result<crate::bodies::Bounds, String> { Ok(b) };
        let (t, moved, kept) = normalize_bodies(fp, 2.0, &mut resolve).unwrap();
        assert_eq!(moved.len(), 1, "{moved:?}");
        assert_eq!(moved[0]["path"], "far.step");
        assert_eq!(moved[0]["offsetAfter"], json!([1.0, 0.0, 0.0]));
        assert_eq!(moved[0]["shiftMm"], 50.0);
        assert_eq!(kept[0]["path"], "near.step");
        assert!(t.contains("(xyz 1 0 0)") && t.contains("(xyz 1.5 0 0)"), "{t}");
        let (_, again, _) = normalize_bodies(&t, 2.0, &mut resolve).unwrap();
        assert!(again.is_empty());
    }
}