master
Colby Knox Merge PR #313: kicad-lane: 1.0.68, BOM, JLCPCB assembly and order, land-pattern footprints, designators e1c2dac 13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881
//! Footprint blocks as KiCad text: place a library footprint on the board, move or rotate
//! one, flip it to the other side, give it fresh UUIDs, set its fields, attributes, lock
//! state, graphics and 3D models. Every function takes one `(footprint ...)` block and
//! returns the edited block; untouched bytes stay exactly as KiCad wrote them.
//!
//! KiCad's file conventions this follows (KiCad 7 to 10):
//! - children positions (`at`, `start`, `end`, `mid`, `center`, `xy`) are footprint-local
//!   and unrotated; the angle in a pad's or text's `(at x y a)` is ABSOLUTE (the footprint's
//!   orientation plus the item's own), so rotating a footprint adds the delta to it;
//! - zones inside a footprint carry absolute board coordinates;
//! - a flip (`FOOTPRINT::Flip`) keeps the anchor where it is, swaps every side-specific layer
//!   (F.x and B.x, inner copper In1 and In(n-2)), negates every local y, and sets the
//!   orientation to -a (top/bottom) or 180-a (left/right, KiCad's default); pad angles become
//!   -a or 180-a, text angles 180-a or -a, and side-specific text toggles its mirror.

use serde_json::{json, Value};

use crate::sxedit::{self, n, q, Edits, Node};

pub type FResult<T> = Result<T, String>;

/// The footprint's `(at x y [rot])`.
pub fn pose(fp: &Node) -> (f64, f64, f64) {
    let at = fp.kid("at");
    (
        at.and_then(|a| a.f64_at(1)).unwrap_or(0.0),
        at.and_then(|a| a.f64_at(2)).unwrap_or(0.0),
        at.and_then(|a| a.f64_at(3)).unwrap_or(0.0),
    )
}

pub fn norm_deg(a: f64) -> f64 {
    let mut r = a.rem_euclid(360.0);
    if (r - 360.0).abs() < 1e-9 {
        r = 0.0;
    }
    (r * 1e6).round() / 1e6
}

fn at_text(x: f64, y: f64, a: f64) -> String {
    let a = norm_deg(a);
    if a == 0.0 {
        format!("(at {} {})", n(x), n(y))
    } else {
        format!("(at {} {} {})", n(x), n(y), n(a))
    }
}

/// KiCad's `FlipLayer` for a board with `copper` copper layers.
pub fn flip_layer(name: &str, copper: usize) -> String {
    if let Some(rest) = name.strip_prefix("F.") {
        return format!("B.{rest}");
    }
    if let Some(rest) = name.strip_prefix("B.") {
        return format!("F.{rest}");
    }
    if let Some(k) = name.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|k| k.parse::<usize>().ok()) {
        let inner = copper.saturating_sub(2);
        if k >= 1 && k <= inner {
            return format!("In{}.Cu", inner + 1 - k);
        }
    }
    name.to_string()
}

fn is_side_specific(layer: &str) -> bool {
    layer.starts_with("F.") || layer.starts_with("B.")
}

/// The footprint's own layer, "F.Cu" or "B.Cu".
pub fn side(fp: &Node) -> String {
    fp.value_of("layer").unwrap_or("F.Cu").to_string()
}

/// Every `(uuid "...")` in the block replaced by a fresh one.
pub fn fresh_uuids(block: &str) -> FResult<String> {
    let root = sxedit::parse(block)?;
    let mut e = Edits::new();
    root.walk(&mut |nd| {
        if matches!(nd.tag(), Some("uuid") | Some("tstamp")) {
            if let Some(v) = nd.kids.get(1) {
                e.replace_node(v, q(&crate::schematic::new_uuid()));
            }
        }
    });
    e.apply(block)
}

fn rotate_local(p: (f64, f64), deg: f64) -> (f64, f64) {
    crate::placement::to_board((0.0, 0.0), deg, p)
}

/// Move and rotate a footprint block to `(x, y, rot)`: the anchor, every absolute child
/// angle by the rotation delta, and the absolute zone outlines with the anchor.
pub fn move_block(block: &str, x: f64, y: f64, rot: f64) -> FResult<String> {
    let fp = sxedit::parse(block)?;
    let (ox, oy, orot) = pose(&fp);
    let delta = rot - orot;
    let mut e = Edits::new();
    match fp.kid("at") {
        Some(at) => e.replace_node(at, at_text(x, y, rot)),
        None => {
            let after = fp.kid("layer").map(|l| l.end).unwrap_or(fp.kids[1].end);
            e.insert(after, format!(" {}", at_text(x, y, rot)));
        }
    }
    for k in fp.kids.iter().skip(1) {
        match k.tag() {
            Some("pad") | Some("property") | Some("fp_text") => {
                if delta.abs() > 1e-9 {
                    if let Some(at) = k.kid("at") {
                        let a = at.f64_at(3).unwrap_or(0.0) + delta;
                        e.replace_node(at, at_text(at.f64_at(1).unwrap_or(0.0), at.f64_at(2).unwrap_or(0.0), a));
                    }
                }
            }
            Some("zone") => {
                k.walk(&mut |nd| {
                    if nd.tag() == Some("xy") {
                        let p = (nd.f64_at(1).unwrap_or(0.0) - ox, nd.f64_at(2).unwrap_or(0.0) - oy);
                        let r = rotate_local(p, delta);
                        e.replace_node(nd, format!("(xy {} {})", n(x + r.0), n(y + r.1)));
                    }
                });
            }
            _ => {}
        }
    }
    e.apply(block)
}

/// Put one of a footprint's fields (`Reference`, `Value`) at a board position (KiCad's frame) at an
/// absolute angle, on `layer`, with a `size` mm font and its stroke, and shown. A field's `at` is
/// in the footprint's own (turned) frame and its angle absolute, as KiCad writes them.
pub fn set_field_pose(block: &str, field: &str, board: (f64, f64), angle: f64, layer: &str, size: f64, thickness: f64) -> FResult<String> {
    let fp = sxedit::parse(block)?;
    let (ox, oy, orot) = pose(&fp);
    let prop = fp.kids_tagged("property").find(|p| p.atom(1) == Some(field)).ok_or_else(|| format!("the footprint has no {field} field"))?;
    // board = origin + R(rot) local, with KiCad's y-down turn (x' = x cos + y sin, y' = -x sin + y cos)
    let (s, c) = orot.to_radians().sin_cos();
    let (dx, dy) = (board.0 - ox, board.1 - oy);
    let local = (dx * c - dy * s, dx * s + dy * c);
    let mut e = Edits::new();
    match prop.kid("at") {
        Some(at) => e.replace_node(at, at_text(local.0, local.1, angle)),
        None => e.insert(prop.kids[2].end, format!(" {}", at_text(local.0, local.1, angle))),
    }
    match prop.kid("layer") {
        Some(l) => e.replace_node(l, format!("(layer {})", sxedit::q(layer))),
        None => e.insert(prop.kids[2].end, format!(" (layer {})", sxedit::q(layer))),
    }
    if let Some(h) = prop.kid("hide") {
        e.remove_node(block, h);
    }
    let font = prop.kid("effects").and_then(|ef| ef.kid("font"));
    match font {
        Some(f) => {
            match f.kid("size") {
                Some(sz) => e.replace_node(sz, format!("(size {} {})", n(size), n(size))),
                None => e.insert(f.kids[0].end, format!(" (size {} {})", n(size), n(size))),
            }
            match f.kid("thickness") {
                Some(t) => e.replace_node(t, format!("(thickness {})", n(thickness))),
                None => e.insert(f.kids[0].end, format!(" (thickness {})", n(thickness))),
            }
        }
        None => match prop.kid("effects") {
            Some(ef) => e.insert(ef.kids[0].end, format!(" (font (size {} {}) (thickness {}))", n(size), n(size), n(thickness))),
            None => e.insert(prop.end - 1, format!(" (effects (font (size {} {}) (thickness {})))", n(size), n(size), n(thickness))),
        },
    }
    // an older (effects ... hide) word
    if let Some(ef) = prop.kid("effects") {
        if let Some(w) = ef.kids.iter().skip(1).find(|k| !k.quoted && k.atom.as_deref() == Some("hide")) {
            e.remove_node(block, w);
        }
    }
    e.apply(block)
}

/// Child kinds a client-side flip handles; anything else refuses the flip.
pub const FLIP_CHILDREN: &[&str] = &[
    "layer", "uuid", "tstamp", "at", "descr", "tags", "property", "path", "sheetname", "sheetfile", "attr", "units", "fp_text", "fp_line", "fp_rect", "fp_circle", "fp_arc",
    "fp_poly", "fp_curve", "pad", "model", "zone", "locked", "embedded_fonts", "embedded_files", "duplicate_pad_numbers_are_jumpers", "jumper_pad_groups", "net_tie_pad_groups",
    "solder_mask_margin", "solder_paste_margin", "solder_paste_margin_ratio", "solder_paste_ratio", "clearance", "zone_connect", "private_layers", "component_classes", "version",
    "generator", "generator_version", "placed", "autoplace_cost90", "autoplace_cost180", "thermal_width", "thermal_gap", "net_tie_pad_groups", "sheetname",
];

fn negate_points(nd: &Node, e: &mut Edits) {
    nd.walk(&mut |p| {
        if matches!(p.tag(), Some("xy") | Some("start") | Some("end") | Some("mid") | Some("center")) && p.kids.len() >= 3 {
            if let (Some(x), Some(y)) = (p.f64_at(1), p.f64_at(2)) {
                let tag = p.tag().unwrap_or("xy");
                e.replace_node(p, format!("({tag} {} {})", n(x), n(-y)));
            }
        }
    });
}

fn flip_layer_kid(nd: &Node, copper: usize, e: &mut Edits) {
    if let Some(l) = nd.kid("layer") {
        if let Some(v) = l.kids.get(1) {
            if let Some(name) = v.atom.as_deref() {
                e.replace_node(v, q(&flip_layer(name, copper)));
            }
        }
    }
}

fn toggle_mirror(text_item: &Node, e: &mut Edits) {
    let Some(effects) = text_item.kid("effects") else { return };
    match effects.kid("justify") {
        Some(j) => {
            let words: Vec<&str> = j.kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect();
            let mut kept: Vec<&str> = words.iter().copied().filter(|w| *w != "mirror").collect();
            if !words.contains(&"mirror") {
                kept.push("mirror");
            }
            if kept.is_empty() {
                e.replace(j.start, j.end, "");
            } else {
                e.replace_node(j, format!("(justify {})", kept.join(" ")));
            }
        }
        None => e.insert(effects.end - 1, " (justify mirror)"),
    }
}

/// Flip a footprint to the other side, about its own anchor, the way KiCad's
/// `FOOTPRINT::Flip` does. `left_right` is KiCad's default flip direction.
pub fn flip_block(block: &str, left_right: bool, copper: usize) -> FResult<String> {
    let fp = sxedit::parse(block)?;
    if fp.tag() != Some("footprint") {
        return Err("not a footprint block".into());
    }
    let (x, y, rot) = pose(&fp);
    let mut e = Edits::new();
    let new_rot = if left_right { 180.0 - rot } else { -rot };
    match fp.kid("at") {
        Some(at) => e.replace_node(at, at_text(x, y, new_rot)),
        None => return Err("footprint has no (at ...)".into()),
    }
    flip_layer_kid(&fp, copper, &mut e);
    for k in fp.kids.iter().skip(2) {
        let Some(tag) = k.tag() else { continue };
        match tag {
            "property" | "fp_text" => {
                if let Some(at) = k.kid("at") {
                    let a = at.f64_at(3).unwrap_or(0.0);
                    let na = if left_right { -a } else { 180.0 - a };
                    e.replace_node(at, at_text(at.f64_at(1).unwrap_or(0.0), -at.f64_at(2).unwrap_or(0.0), na));
                }
                let layer = k.value_of("layer").unwrap_or("");
                flip_layer_kid(k, copper, &mut e);
                if is_side_specific(layer) {
                    toggle_mirror(k, &mut e);
                }
            }
            "fp_line" | "fp_rect" | "fp_circle" | "fp_arc" | "fp_poly" | "fp_curve" => {
                negate_points(k, &mut e);
                flip_layer_kid(k, copper, &mut e);
            }
            "pad" => flip_pad(k, left_right, copper, &mut e)?,
            "zone" => {
                // absolute coordinates: mirror about the anchor; fills are dropped (KiCad refills)
                k.walk(&mut |nd| {
                    if nd.tag() == Some("filled_polygon") {
                        e.replace(nd.start, nd.end, "");
                    }
                });
                if let Some(poly) = k.kid("polygon") {
                    poly.walk(&mut |p| {
                        if p.tag() == Some("xy") {
                            let (px, py) = (p.f64_at(1).unwrap_or(0.0), p.f64_at(2).unwrap_or(0.0));
                            let (nx, ny) = if left_right { (2.0 * x - px, py) } else { (px, 2.0 * y - py) };
                            e.replace_node(p, format!("(xy {} {})", n(nx), n(ny)));
                        }
                    });
                }
                if let Some(ls) = k.kid("layers") {
                    let names: Vec<String> = ls.kids.iter().skip(1).filter_map(|a| a.atom.as_deref()).map(|l| q(&flip_layer(l, copper))).collect();
                    e.replace_node(ls, format!("(layers {})", names.join(" ")));
                }
                flip_layer_kid(k, copper, &mut e);
            }
            "model" => {
                let nonzero = |t: &str, i: usize| k.kid(t).and_then(|o| o.kid("xyz")).and_then(|v| v.f64_at(i)).map(|v| v.abs() > 1e-9).unwrap_or(false);
                if nonzero("offset", 2) || nonzero("rotate", 1) || nonzero("rotate", 2) {
                    return Err(format!("the 3D model {} has a y offset or an x/y rotation that a flip would have to mirror; the bridge does not reproduce KiCad's model transform", k.atom(1).unwrap_or("")));
                }
            }
            t if FLIP_CHILDREN.contains(&t) => {}
            other => return Err(format!("footprint carries a ({other} ...) child the bridge cannot flip")),
        }
    }
    e.apply(block)
}

fn flip_pad(pad: &Node, left_right: bool, copper: usize, e: &mut Edits) -> FResult<()> {
    if let Some(at) = pad.kid("at") {
        let a = at.f64_at(3).unwrap_or(0.0);
        let na = if left_right { 180.0 - a } else { -a };
        e.replace_node(at, at_text(at.f64_at(1).unwrap_or(0.0), -at.f64_at(2).unwrap_or(0.0), na));
    }
    if let Some(ls) = pad.kid("layers") {
        let names: Vec<String> = ls.kids.iter().skip(1).filter_map(|a| a.atom.as_deref()).map(|l| q(&flip_layer(l, copper))).collect();
        e.replace_node(ls, format!("(layers {})", names.join(" ")));
    }
    // pad-local geometry: drill offset, trapezoid delta, custom primitives
    if let Some(drill) = pad.kid("drill") {
        if let Some(off) = drill.kid("offset") {
            if let (Some(ox), Some(oy)) = (off.f64_at(1), off.f64_at(2)) {
                e.replace_node(off, format!("(offset {} {})", n(ox), n(-oy)));
            }
        }
    }
    if let Some(d) = pad.kid("rect_delta") {
        if let (Some(dx), Some(dy)) = (d.f64_at(1), d.f64_at(2)) {
            e.replace_node(d, format!("(rect_delta {} {})", n(dx), n(-dy)));
        }
    }
    if let Some(prims) = pad.kid("primitives") {
        negate_points(prims, e);
    }
    if let Some(ch) = pad.kid("chamfer") {
        let words: Vec<String> = ch
            .kids
            .iter()
            .skip(1)
            .filter_map(|k| k.atom.as_deref())
            .map(|w| match w {
                "top_left" => "bottom_left",
                "bottom_left" => "top_left",
                "top_right" => "bottom_right",
                "bottom_right" => "top_right",
                o => o,
            }.to_string())
            .collect();
        e.replace_node(ch, format!("(chamfer {})", words.join(" ")));
    }
    if pad.kid("padstack").is_some() {
        return Err(format!("pad {} has a per-layer padstack the bridge does not flip", pad.atom(1).unwrap_or("")));
    }
    Ok(())
}

/// Set (or add) a property's value. A new property is hidden on the Fab layer of the
/// footprint's side.
pub fn set_property(block: &str, name: &str, value: &str) -> FResult<String> {
    let fp = sxedit::parse(block)?;
    let mut e = Edits::new();
    match fp.kids_tagged("property").find(|p| p.atom(1) == Some(name)) {
        Some(p) => match p.kids.get(2) {
            Some(v) => e.replace_node(v, q(value)),
            None => e.insert(p.kids[1].end, format!(" {}", q(value))),
        },
        None => {
            let fab = if side(&fp) == "B.Cu" { "B.Fab" } else { "F.Fab" };
            let anchor = fp.kids_tagged("property").last().map(|p| p.end).or_else(|| fp.kid("at").map(|a| a.end)).unwrap_or(fp.kids[1].end);
            let rot = pose(&fp).2;
            e.insert(
                anchor,
                format!(
                    "\n\t\t(property {} {}\n\t\t\t{}\n\t\t\t(layer {})\n\t\t\t(hide yes)\n\t\t\t(uuid {})\n\t\t\t(effects (font (size 1 1) (thickness 0.15)))\n\t\t)",
                    q(name),
                    q(value),
                    at_text(0.0, 0.0, rot),
                    q(fab),
                    q(&crate::schematic::new_uuid())
                ),
            );
        }
    }
    e.apply(block)
}

/// Remove a user property (the four mandatory fields are refused).
pub fn remove_property(block: &str, name: &str) -> FResult<String> {
    if ["Reference", "Value", "Footprint", "Datasheet", "Description"].contains(&name) {
        return Err(format!("{name} is a mandatory field and cannot be removed"));
    }
    let fp = sxedit::parse(block)?;
    let Some(p) = fp.kids_tagged("property").find(|p| p.atom(1) == Some(name)) else {
        return Err(format!("no property named {name}"));
    };
    let mut e = Edits::new();
    e.remove_node(block, p);
    e.apply(block)
}

/// Every property (name, value).
pub fn properties(fp: &Node) -> Vec<(String, String)> {
    fp.kids_tagged("property").filter_map(|p| Some((p.atom(1)?.to_string(), p.atom(2).unwrap_or("").to_string()))).collect()
}

/// The `(attr ...)` words that are flags (everything but the mounting type).
pub const ATTR_FLAGS: &[(&str, &str)] = &[
    ("excludeFromPosFiles", "exclude_from_pos_files"),
    ("excludeFromBom", "exclude_from_bom"),
    ("dnp", "dnp"),
    ("boardOnly", "board_only"),
    ("allowMissingCourtyard", "allow_missing_courtyard"),
    ("allowSoldermaskBridges", "allow_soldermask_bridges"),
];

/// The footprint's attributes as JSON: type (smd, through_hole, unspecified) and each flag.
pub fn attrs(fp: &Node) -> Value {
    let words: Vec<&str> = fp.kid("attr").map(|a| a.kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect()).unwrap_or_default();
    let mut v = json!({"type": words.iter().find(|w| **w == "smd" || **w == "through_hole").copied().unwrap_or("unspecified")});
    for (k, w) in ATTR_FLAGS {
        v[*k] = json!(words.contains(w));
    }
    v
}

/// Set attribute flags (`changes` maps the JSON names of `ATTR_FLAGS` to bools; `type` too).
pub fn set_attrs(block: &str, changes: &serde_json::Map<String, Value>) -> FResult<String> {
    let fp = sxedit::parse(block)?;
    let cur = attrs(&fp);
    let ty = match changes.get("type") {
        Some(Value::String(t)) if matches!(t.as_str(), "smd" | "through_hole" | "unspecified") => t.clone(),
        Some(_) => return Err("type must be smd, through_hole or unspecified".into()),
        None => cur["type"].as_str().unwrap_or("unspecified").to_string(),
    };
    let mut words: Vec<&str> = Vec::new();
    if ty != "unspecified" {
        words.push(if ty == "smd" { "smd" } else { "through_hole" });
    }
    for (k, w) in ATTR_FLAGS {
        let on = match changes.get(*k) {
            Some(Value::Bool(b)) => *b,
            Some(_) => return Err(format!("{k} must be true or false")),
            None => cur[*k].as_bool().unwrap_or(false),
        };
        if on {
            words.push(w);
        }
    }
    let text = if words.is_empty() { String::new() } else { format!("(attr {})", words.join(" ")) };
    let mut e = Edits::new();
    match fp.kid("attr") {
        Some(a) if text.is_empty() => e.remove_node(block, a),
        Some(a) => e.replace_node(a, text),
        None if text.is_empty() => {}
        None => {
            let anchor = fp.kid("sheetfile").or_else(|| fp.kids_tagged("property").last()).or_else(|| fp.kid("at")).map(|n| n.end).unwrap_or(fp.kids[1].end);
            e.insert(anchor, format!("\n\t\t{text}"));
        }
    }
    e.apply(block)
}

/// Lock or unlock: `(locked yes)` after the layer, as KiCad 9 and 10 write it (the bare
/// `locked` word of older files is removed either way).
pub fn set_locked(block: &str, locked: bool) -> FResult<String> {
    let fp = sxedit::parse(block)?;
    let mut e = Edits::new();
    for k in fp.kids.iter().skip(2) {
        if k.tag() == Some("locked") || (!k.quoted && k.atom.as_deref() == Some("locked")) {
            e.replace(k.start, k.end, "");
        }
    }
    if locked {
        let after = fp.kid("layer").map(|l| l.end).unwrap_or(fp.kids[1].end);
        e.insert(after, " (locked yes)");
    }
    e.apply(block)
}

pub fn is_locked(fp: &Node) -> bool {
    fp.kid("locked").map(|l| l.atom(1) != Some("no")).unwrap_or(false) || fp.has_word("locked")
}

/// Pad nets removed (a duplicate is a new part; it must not short its source's nets).
pub fn strip_nets(block: &str) -> FResult<String> {
    let fp = sxedit::parse(block)?;
    let mut e = Edits::new();
    for pad in fp.kids_tagged("pad") {
        if let Some(net) = pad.kid("net") {
            e.replace(net.start, net.end, "");
        }
    }
    e.apply(block)
}

/// The symbol link removed (`path`, `sheetname`, `sheetfile`): the copy is not in the schematic.
pub fn strip_symbol_link(block: &str) -> FResult<String> {
    let fp = sxedit::parse(block)?;
    let mut e = Edits::new();
    for t in ["path", "sheetname", "sheetfile"] {
        if let Some(k) = fp.kid(t) {
            e.remove_node(block, k);
        }
    }
    e.apply(block)
}

/// A library footprint (`.kicad_mod` text) as a board footprint at `(x, y, rot)` on `side`,
/// with the given reference (and value when given), fresh UUIDs, named `lib_id`.
pub fn place_library(lib_text: &str, lib_id: &str, x: f64, y: f64, rot: f64, back: bool, reference: &str, value: Option<&str>, copper: usize) -> FResult<String> {
    let root = sxedit::parse(lib_text)?;
    if !matches!(root.tag(), Some("footprint") | Some("module")) {
        return Err("the library file is not a (footprint ...)".into());
    }
    let mut e = Edits::new();
    e.replace_node(&root.kids[0], "footprint");
    match root.kids.get(1) {
        Some(name) if name.atom.is_some() => e.replace_node(name, q(lib_id)),
        _ => return Err("the library footprint has no name".into()),
    }
    for t in ["version", "generator", "generator_version", "at", "uuid", "tstamp", "locked", "placed"] {
        for k in root.kids_tagged(t) {
            e.remove_node(lib_text, k);
        }
    }
    let after = root.kid("layer").map(|l| l.end).unwrap_or(root.kids[1].end);
    if root.kid("layer").is_none() {
        e.insert(after, " (layer \"F.Cu\")");
    }
    e.insert(after, format!("\n\t\t(uuid {})\n\t\t(at 0 0)", q(&crate::schematic::new_uuid())));
    let text = e.apply(lib_text)?;
    let text = fresh_uuids(&text)?;
    let mut text = set_property(&text, "Reference", reference)?;
    if let Some(v) = value {
        text = set_property(&text, "Value", v)?;
    }
    if back {
        text = flip_block(&text, true, copper)?;
    }
    move_block(&text, x, y, rot)
}

/// The board's copper layer count from its `(layers ...)` table.
pub fn copper_count(root: &Node) -> usize {
    root.kid("layers").map(|ls| ls.kids.iter().skip(1).filter(|l| l.atom(1).map(|n| n.ends_with(".Cu")).unwrap_or(false)).count()).unwrap_or(2).max(2)
}

/// Graphics of a placed footprint: every fp_line/rect/circle/arc/poly/curve and fp_text,
/// with its layer, kind, local points, width and uuid.
pub fn graphics(fp: &Node) -> Vec<Value> {
    let mut out = Vec::new();
    for k in fp.kids.iter().skip(2) {
        let Some(tag) = k.tag() else { continue };
        let kind = match tag {
            "fp_line" => "line",
            "fp_rect" => "rect",
            "fp_circle" => "circle",
            "fp_arc" => "arc",
            "fp_poly" => "polygon",
            "fp_curve" => "bezier",
            "fp_text" => "text",
            _ => continue,
        };
        let pt = |t: &str| k.kid(t).and_then(|p| Some(json!([p.f64_at(1)?, p.f64_at(2)?])));
        let mut row = json!({"uuid": k.uuid(), "kind": kind, "layer": k.value_of("layer")});
        for t in ["start", "mid", "end", "center"] {
            if let Some(p) = pt(t) {
                row[t] = p;
            }
        }
        if let Some(pts) = k.kid("pts") {
            row["points"] = json!(pts.kids_tagged("xy").filter_map(|p| Some([p.f64_at(1)?, p.f64_at(2)?])).collect::<Vec<_>>());
        }
        if let Some(w) = k.kid("stroke").and_then(|s| s.kid("width")).and_then(|w| w.f64_at(1)).or_else(|| k.kid("width").and_then(|w| w.f64_at(1))) {
            row["width"] = json!(w);
        }
        if let Some(f) = k.kid("fill") {
            row["fill"] = json!(f.atom(1));
        }
        if tag == "fp_text" {
            row["text"] = json!(k.atom(2));
            row["textKind"] = json!(k.atom(1));
        }
        out.push(row);
    }
    out
}

/// Edit one footprint graphic by uuid: `layer`, `width`, `points` (polygon) or any of
/// `start`, `mid`, `end`, `center` as [x, y] in footprint-local mm, `fill`, or `delete:true`.
pub fn edit_graphic(block: &str, uuid: &str, change: &Value) -> FResult<String> {
    let fp = sxedit::parse(block)?;
    let Some(item) = fp.kids.iter().skip(2).find(|k| k.tag().map(|t| t.starts_with("fp_")).unwrap_or(false) && k.uuid() == Some(uuid)) else {
        return Err(format!("no footprint graphic with uuid {uuid}"));
    };
    let mut e = Edits::new();
    if change.get("delete") == Some(&json!(true)) {
        e.remove_node(block, item);
        return e.apply(block);
    }
    let pair = |v: &Value| -> FResult<(f64, f64)> {
        let a = v.as_array().filter(|a| a.len() == 2).ok_or("a point is [x, y] in mm")?;
        let (x, y) = (a[0].as_f64().ok_or("x must be a number")?, a[1].as_f64().ok_or("y must be a number")?);
        if !x.is_finite() || !y.is_finite() {
            return Err("points must be finite".into());
        }
        Ok((x, y))
    };
    let mut touched = false;
    for t in ["start", "mid", "end", "center"] {
        if let Some(v) = change.get(t) {
            let (x, y) = pair(v)?;
            match item.kid(t) {
                Some(p) => e.replace_node(p, format!("({t} {} {})", n(x), n(y))),
                None => return Err(format!("this {} has no {t} point", item.tag().unwrap_or(""))),
            }
            touched = true;
        }
    }
    if let Some(v) = change.get("points") {
        let pts = v.as_array().ok_or("points is a list of [x, y]")?;
        if pts.len() < 2 {
            return Err("points needs at least 2 vertices".into());
        }
        let Some(old) = item.kid("pts") else { return Err("only a polygon or bezier takes points".into()) };
        let mut s = String::from("(pts");
        for p in pts {
            let (x, y) = pair(p)?;
            s.push_str(&format!(" (xy {} {})", n(x), n(y)));
        }
        s.push(')');
        e.replace_node(old, s);
        touched = true;
    }
    if let Some(w) = change.get("width") {
        let w = w.as_f64().filter(|w| w.is_finite() && *w >= 0.0 && *w < 100.0).ok_or("width is mm, 0 to 100")?;
        match item.kid("stroke").and_then(|s| s.kid("width")) {
            Some(old) => e.replace_node(old, format!("(width {})", n(w))),
            None => match item.kid("width") {
                Some(old) => e.replace_node(old, format!("(width {})", n(w))),
                None => e.insert(item.end - 1, format!(" (stroke (width {}) (type solid))", n(w))),
            },
        }
        touched = true;
    }
    if let Some(l) = change.get("layer") {
        let l = l.as_str().ok_or("layer is a layer name")?;
        match item.kid("layer").and_then(|k| k.kids.get(1)) {
            Some(v) => e.replace_node(v, q(l)),
            None => return Err("this graphic has no layer".into()),
        }
        touched = true;
    }
    if let Some(f) = change.get("fill") {
        let f = match f {
            Value::Bool(true) => "yes",
            Value::Bool(false) => "no",
            Value::String(s) if s == "yes" || s == "no" || s == "solid" || s == "none" => if s == "solid" { "yes" } else if s == "none" { "no" } else { s.as_str() },
            _ => return Err("fill is true or false".into()),
        };
        match item.kid("fill") {
            Some(old) => e.replace_node(old, format!("(fill {f})")),
            None => e.insert(item.end - 1, format!(" (fill {f})")),
        }
        touched = true;
    }
    if !touched {
        return Err("nothing to change: pass layer, width, points, start/mid/end/center, fill or delete:true".into());
    }
    e.apply(block)
}

/// A footprint's 3D model entries.
pub fn models(fp: &Node) -> Vec<Value> {
    fp.kids_tagged("model")
        .enumerate()
        .map(|(i, m)| {
            let v3 = |t: &str, d: f64| -> Value {
                let x = m.kid(t).and_then(|o| o.kid("xyz"));
                json!([x.and_then(|v| v.f64_at(1)).unwrap_or(d), x.and_then(|v| v.f64_at(2)).unwrap_or(d), x.and_then(|v| v.f64_at(3)).unwrap_or(d)])
            };
            let hidden = m.has_word("hide") || m.kid("hide").map(|h| h.atom(1) != Some("no")).unwrap_or(false);
            json!({"index": i, "path": m.atom(1), "offset": v3("offset", 0.0), "scale": v3("scale", 1.0), "rotate": v3("rotate", 0.0), "visible": !hidden,
                   "opacity": m.kid("opacity").and_then(|o| o.f64_at(1))})
        })
        .collect()
}

fn xyz(v: Option<&Value>, d: [f64; 3], what: &str) -> FResult<[f64; 3]> {
    match v {
        None | Some(Value::Null) => Ok(d),
        Some(Value::Array(a)) if a.len() == 3 => {
            let mut o = [0.0; 3];
            for (i, x) in a.iter().enumerate() {
                o[i] = x.as_f64().filter(|f| f.is_finite()).ok_or(format!("{what} must be three finite numbers"))?;
            }
            Ok(o)
        }
        _ => Err(format!("{what} must be [x, y, z]")),
    }
}

fn model_text(path: &str, offset: [f64; 3], scale: [f64; 3], rotate: [f64; 3], hidden: bool) -> String {
    let v = |a: [f64; 3]| format!("(xyz {} {} {})", n(a[0]), n(a[1]), n(a[2]));
    format!("(model {}{}\n\t\t\t(offset\n\t\t\t\t{}\n\t\t\t)\n\t\t\t(scale\n\t\t\t\t{}\n\t\t\t)\n\t\t\t(rotate\n\t\t\t\t{}\n\t\t\t)\n\t\t)", q(path), if hidden { " (hide yes)" } else { "" }, v(offset), v(scale), v(rotate))
}

/// Add, replace or remove 3D model entries. `ops` is a list of
/// `{action: add|replace|remove, index?, path?, offset?, scale?, rotate?, visible?}`.
pub fn edit_models(block: &str, ops: &[Value]) -> FResult<String> {
    let mut text = block.to_string();
    for op in ops {
        let fp = sxedit::parse(&text)?;
        let existing: Vec<&Node> = fp.kids_tagged("model").collect();
        let action = op.get("action").and_then(Value::as_str).unwrap_or("");
        let index = || -> FResult<usize> {
            match op.get("index").and_then(Value::as_u64) {
                Some(i) if (i as usize) < existing.len() => Ok(i as usize),
                Some(i) => Err(format!("model index {i} out of range ({} models)", existing.len())),
                None => match op.get("path").and_then(Value::as_str) {
                    Some(p) => existing.iter().position(|m| m.atom(1) == Some(p)).ok_or(format!("no model with path {p}")),
                    None if existing.len() == 1 => Ok(0),
                    None => Err("name the model with index or path".into()),
                },
            }
        };
        let mut e = Edits::new();
        match action {
            "add" => {
                let path = op.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("add needs path")?;
                let t = model_text(path, xyz(op.get("offset"), [0.0; 3], "offset")?, xyz(op.get("scale"), [1.0; 3], "scale")?, xyz(op.get("rotate"), [0.0; 3], "rotate")?, op.get("visible") == Some(&json!(false)));
                e.insert(fp.end - 1, format!("\t{t}\n\t"));
            }
            "replace" => {
                let i = index()?;
                let old = models(&fp)[i].clone();
                let get = |k: &str, d: &Value| op.get(k).cloned().unwrap_or_else(|| d.clone());
                let path = op.get("newPath").or_else(|| if op.get("index").is_some() { op.get("path") } else { None }).and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| old["path"].as_str().unwrap_or("").to_string());
                let t = model_text(
                    &path,
                    xyz(Some(&get("offset", &old["offset"])), [0.0; 3], "offset")?,
                    xyz(Some(&get("scale", &old["scale"])), [1.0; 3], "scale")?,
                    xyz(Some(&get("rotate", &old["rotate"])), [0.0; 3], "rotate")?,
                    !get("visible", &old["visible"]).as_bool().unwrap_or(true),
                );
                e.replace_node(existing[i], t);
            }
            "remove" => {
                let i = index()?;
                e.remove_node(&text, existing[i]);
            }
            other => return Err(format!("action must be add, replace or remove, not '{other}'")),
        }
        text = e.apply(&text)?;
    }
    Ok(text)
}

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

    #[test]
    fn a_field_lands_where_asked_on_a_turned_footprint() {
        let block = "(footprint \"R:R_0603\" (layer \"F.Cu\") (at 100 50 90) (property \"Reference\" \"R1\" (at 0 -1.43 90) (layer \"F.Fab\") (hide yes) (uuid \"u\") (effects (font (size 0.5 0.5) (thickness 0.08)))))";
        let out = set_field_pose(block, "Reference", (100.0, 47.0), 0.0, "F.SilkS", 1.0, 0.15).unwrap();
        // 3 mm above the origin on the board is (3, 0) in a footprint turned 90 (KiCad's y down)
        assert!(out.contains("(at 3 0)") && out.contains("(layer \"F.SilkS\")") && out.contains("(size 1 1)") && out.contains("(thickness 0.15)") && !out.contains("hide"), "{out}");
        let fp = sxedit::parse(&out).unwrap();
        let p = fp.kids_tagged("property").next().unwrap();
        let at = p.kid("at").unwrap();
        let (lx, ly) = (at.f64_at(1).unwrap(), at.f64_at(2).unwrap());
        let (s, c) = 90f64.to_radians().sin_cos();
        assert!(((100.0 + lx * c + ly * s) - 100.0).abs() < 1e-9 && ((50.0 - lx * s + ly * c) - 47.0).abs() < 1e-9);
    }
}

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

    const CAP: &str = "(footprint \"Capacitor_SMD:C_0805\"\n\t\t(layer \"F.Cu\")\n\t\t(uuid \"u-fp\")\n\t\t(at 10 20 90)\n\t\t(property \"Reference\" \"C7\" (at 0 -1.68 90) (layer \"F.SilkS\") (uuid \"u-ref\") (effects (font (size 1 1) (thickness 0.15))))\n\t\t(property \"Value\" \"1u\" (at 0 1.68 90) (layer \"F.Fab\") (uuid \"u-val\") (effects (font (size 1 1))))\n\t\t(attr smd)\n\t\t(fp_line (start -0.26 -0.73) (end 0.26 -0.73) (stroke (width 0.12) (type solid)) (layer \"F.SilkS\") (uuid \"u-l1\"))\n\t\t(fp_poly (pts (xy -1.7 -0.98) (xy 1.7 -0.98) (xy 1.7 0.98)) (stroke (width 0.05) (type solid)) (fill no) (layer \"F.CrtYd\") (uuid \"u-cy\"))\n\t\t(pad \"1\" smd roundrect (at -0.95 0.2 90) (size 1 1.45) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (chamfer top_left) (net \"+VBAT\") (uuid \"u-p1\"))\n\t\t(pad \"2\" thru_hole circle (at 0.95 0 90) (size 1 1) (drill 0.5 (offset 0 0.1)) (layers \"*.Cu\" \"*.Mask\") (net \"GND\") (uuid \"u-p2\"))\n\t\t(model \"x.step\" (offset (xyz 1 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 90)))\n\t)";

    #[test]
    fn flip_left_right_mirrors_like_kicad() {
        let out = flip_block(CAP, true, 4).unwrap();
        let fp = sxedit::parse(&out).unwrap();
        assert_eq!(fp.value_of("layer"), Some("B.Cu"));
        assert_eq!(pose(&fp), (10.0, 20.0, 90.0)); // 180 - 90
        let p1 = fp.kids_tagged("pad").next().unwrap();
        let at = p1.kid("at").unwrap();
        assert_eq!((at.f64_at(1), at.f64_at(2), at.f64_at(3)), (Some(-0.95), Some(-0.2), Some(90.0)));
        assert_eq!(p1.kid("layers").unwrap().kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect::<Vec<_>>(), vec!["B.Cu", "B.Mask", "B.Paste"]);
        assert_eq!(p1.kid("chamfer").unwrap().atom(1), Some("bottom_left"));
        let p2 = fp.kids_tagged("pad").nth(1).unwrap();
        assert_eq!(p2.kid("layers").unwrap().atom(1), Some("*.Cu"));
        assert_eq!(p2.kid("drill").unwrap().kid("offset").unwrap().f64_at(2), Some(-0.1));
        let r = fp.kids_tagged("property").next().unwrap();
        assert_eq!(r.value_of("layer"), Some("B.SilkS"));
        assert_eq!(r.kid("at").unwrap().f64_at(2), Some(1.68));
        assert_eq!(r.kid("at").unwrap().f64_at(3), Some(270.0)); // -90
        assert_eq!(r.kid("effects").unwrap().kid("justify").unwrap().atom(1), Some("mirror"));
        let cy = fp.kids_tagged("fp_poly").next().unwrap();
        assert_eq!(cy.value_of("layer"), Some("B.CrtYd"));
        assert_eq!(cy.kid("pts").unwrap().kids[1].f64_at(2), Some(0.98));
        // flipping twice is the identity on every coordinate and layer
        let back = flip_block(&out, true, 4).unwrap();
        let b = sxedit::parse(&back).unwrap();
        assert_eq!(pose(&b), (10.0, 20.0, 90.0));
        assert_eq!(b.value_of("layer"), Some("F.Cu"));
        assert!(b.kids_tagged("property").next().unwrap().kid("effects").unwrap().kid("justify").is_none());
        assert_eq!(flip_layer("In1.Cu", 6), "In4.Cu");
        assert_eq!(flip_layer("In2.Cu", 4), "In1.Cu");
    }

    #[test]
    fn a_model_the_flip_would_have_to_move_is_refused() {
        let bad = CAP.replace("(offset (xyz 1 0 0))", "(offset (xyz 1 0.5 0))");
        assert!(flip_block(&bad, true, 2).unwrap_err().contains("3D model"));
    }

    #[test]
    fn move_rotates_absolute_angles_and_fields_attrs_lock_edit_in_place() {
        let out = move_block(CAP, 50.0, 60.0, 180.0).unwrap();
        let fp = sxedit::parse(&out).unwrap();
        assert_eq!(pose(&fp), (50.0, 60.0, 180.0));
        assert_eq!(fp.kids_tagged("pad").next().unwrap().kid("at").unwrap().f64_at(3), Some(180.0));
        assert_eq!(fp.kids_tagged("property").next().unwrap().kid("at").unwrap().f64_at(3), Some(180.0));
        let v = set_property(&out, "Value", "2u2").unwrap();
        let v = set_property(&v, "LCSC", "C1234").unwrap();
        let fp = sxedit::parse(&v).unwrap();
        assert!(properties(&fp).contains(&("Value".into(), "2u2".into())));
        assert!(properties(&fp).contains(&("LCSC".into(), "C1234".into())));
        let mut ch = serde_json::Map::new();
        ch.insert("dnp".into(), json!(true));
        ch.insert("excludeFromBom".into(), json!(true));
        let a = set_attrs(&v, &ch).unwrap();
        let fp = sxedit::parse(&a).unwrap();
        assert_eq!(attrs(&fp), json!({"type":"smd","excludeFromPosFiles":false,"excludeFromBom":true,"dnp":true,"boardOnly":false,"allowMissingCourtyard":false,"allowSoldermaskBridges":false}));
        let l = set_locked(&a, true).unwrap();
        assert!(is_locked(&sxedit::parse(&l).unwrap()));
        assert!(!is_locked(&sxedit::parse(&set_locked(&l, false).unwrap()).unwrap()));
        assert!(remove_property(&v, "Value").is_err());
        let r = remove_property(&v, "LCSC").unwrap();
        assert!(!r.contains("LCSC"));
    }

    #[test]
    fn graphics_and_models_list_and_edit() {
        let fp = sxedit::parse(CAP).unwrap();
        let g = graphics(&fp);
        assert_eq!(g.len(), 2);
        assert_eq!(g[0]["kind"], "line");
        assert_eq!(g[1]["points"].as_array().unwrap().len(), 3);
        let e = edit_graphic(CAP, "u-l1", &json!({"width": 0.2, "layer": "F.Fab", "end": [0.3, -0.73]})).unwrap();
        let g = graphics(&sxedit::parse(&e).unwrap());
        assert_eq!((g[0]["width"].clone(), g[0]["layer"].clone(), g[0]["end"].clone()), (json!(0.2), json!("F.Fab"), json!([0.3, -0.73])));
        assert!(edit_graphic(CAP, "nope", &json!({"width": 1})).is_err());
        let d = edit_graphic(CAP, "u-cy", &json!({"delete": true})).unwrap();
        assert_eq!(graphics(&sxedit::parse(&d).unwrap()).len(), 1);
        let m = edit_models(CAP, &[json!({"action":"replace","index":0,"offset":[0,0,0.5]}), json!({"action":"add","path":"${KIPRJMOD}/y.step","rotate":[0,0,90]})]).unwrap();
        let ms = models(&sxedit::parse(&m).unwrap());
        assert_eq!(ms.len(), 2);
        assert_eq!(ms[0]["offset"], json!([0.0, 0.0, 0.5]));
        assert_eq!(ms[0]["rotate"], json!([0.0, 0.0, 90.0]));
        assert_eq!(ms[1]["path"], "${KIPRJMOD}/y.step");
        let r = edit_models(&m, &[json!({"action":"remove","path":"x.step"})]).unwrap();
        assert_eq!(models(&sxedit::parse(&r).unwrap()).len(), 1);
    }

    #[test]
    fn a_library_footprint_lands_on_the_back_with_fresh_ids() {
        let lib = "(footprint \"R_0603\" (version 20240108) (generator \"pcbnew\") (layer \"F.Cu\") (descr \"r\")\n (property \"Reference\" \"REF**\" (at 0 -1.4 0) (layer \"F.SilkS\") (uuid \"aaa\") (effects (font (size 1 1))))\n (property \"Value\" \"R_0603\" (at 0 1.4 0) (layer \"F.Fab\") (uuid \"bbb\") (effects (font (size 1 1))))\n (attr smd)\n (pad \"1\" smd roundrect (at -0.8 0) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (uuid \"ccc\"))\n (pad \"2\" smd roundrect (at 0.8 0) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (uuid \"ddd\"))\n)";
        let out = place_library(lib, "Resistor_SMD:R_0603", 100.0, 50.0, 90.0, true, "R7", Some("10k"), 2).unwrap();
        let fp = sxedit::parse(&out).unwrap();
        assert_eq!(fp.atom(1), Some("Resistor_SMD:R_0603"));
        assert_eq!(fp.value_of("layer"), Some("B.Cu"));
        assert_eq!(pose(&fp), (100.0, 50.0, 90.0));
        assert!(fp.kid("version").is_none() && fp.kid("generator").is_none());
        assert!(!out.contains("\"ccc\"") && !out.contains("\"aaa\""));
        assert!(properties(&fp).contains(&("Reference".into(), "R7".into())));
        let p1 = fp.kids_tagged("pad").next().unwrap();
        // flipped left/right at rotation 0 (orientation 180), then turned to 90: pad angle 180 - 0 + (90 - 180)
        assert_eq!(p1.kid("at").unwrap().f64_at(3), Some(90.0));
        assert_eq!(p1.kid("layers").unwrap().atom(1), Some("B.Cu"));
        // parses as a board footprint
        let board = format!("(kicad_pcb (version 20260206) (generator \"t\") {out})");
        let b = crate::pcb::parse_pcb_text(&board, "t.kicad_pcb").unwrap();
        assert_eq!(b.footprints[0].reference, "R7");
    }
}