master
Colby Knox Merge PR #368: kicad-lane: 1.0.96, net_connections, remove_items and doc_params answered fully ef6c1f3 11d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128
//! Board and footprint edits as pure text transforms: the board outline, the copper layer
//! count, board graphics, mounting holes, SVG artwork, footprints added, flipped, deleted,
//! edited, updated from their library, their graphics and 3D models, alignment plans,
//! schematic cross-probing, the title block and the board's extents.
//!
//! Every edit returns a [`Change`]: the whole candidate board text (what a closed file
//! becomes, and what KiCad's DRC checks before a live commit), the top-level item ids to
//! delete and the blocks to create when the same change is committed live through the IPC
//! API, and the per-verb report. The file and the live path therefore make exactly the
//! same edit, and a live commit is checked against the very text a file would get.

use std::path::{Path, PathBuf};

use serde_json::{json, Map, Value};

use crate::fp_text;
use crate::pcb::{self, Board, NetFormat, RoutingError};
use crate::placement;
use crate::schematic::new_uuid;
use crate::sxedit::{self, n, q, Edits, Node};

pub type RResult<T> = Result<T, RoutingError>;

/// One edit, ready for a closed file or a live commit.
#[derive(Clone, Debug, Default)]
pub struct Change {
    /// The whole board after the edit.
    pub candidate: String,
    /// Top-level item UUIDs a live commit deletes.
    pub remove: Vec<String>,
    /// Top-level blocks a live commit creates (fresh UUIDs, so they never collide with an
    /// item the same commit deletes).
    pub create: Vec<String>,
    /// The edit can change copper (so a live commit runs KiCad's DRC first).
    pub copper: bool,
    /// What the verb reports.
    pub report: Value,
    /// Footprints the read-back must show (or not show) afterwards.
    pub expect: Vec<Expect>,
    /// The native Undo entry.
    pub label: String,
}

/// A read-back expectation: the footprint `reference` is present (with this pose and side)
/// or gone.
#[derive(Clone, Debug, PartialEq)]
pub struct Expect {
    pub reference: String,
    pub present: bool,
    pub pose: Option<(f64, f64, f64)>,
    pub side: Option<String>,
}

fn err(code: &str, msg: impl Into<String>) -> RoutingError {
    RoutingError::new(code, msg).with("mutated", json!(false))
}

fn parse_root(text: &str) -> RResult<Node> {
    let r = sxedit::parse(text).map_err(|e| err("board_unparsable", format!("could not read the board: {e}")))?;
    if r.tag() != Some("kicad_pcb") {
        return Err(err("not_a_board", "the text is not a (kicad_pcb ...)"));
    }
    Ok(r)
}

fn parse_board(text: &str) -> RResult<Board> {
    pcb::parse_pcb_text(text, "board.kicad_pcb").map_err(|e| err("board_unparsable", e))
}

/// The footprint's reference (the Reference property, or a KiCad 6 `fp_text reference`).
pub fn fp_ref(fp: &Node) -> String {
    fp.kids_tagged("property")
        .find(|p| p.atom(1) == Some("Reference"))
        .and_then(|p| p.atom(2))
        .or_else(|| fp.kids_tagged("fp_text").find(|t| t.atom(1) == Some("reference")).and_then(|t| t.atom(2)))
        .unwrap_or("")
        .to_string()
}

pub fn footprints(root: &Node) -> Vec<&Node> {
    root.kids_tagged("footprint").collect()
}

fn refs_arg(args: &Value) -> RResult<Vec<String>> {
    let list: Vec<String> = match (args.get("refs"), args.get("ref")) {
        (Some(Value::Array(a)), _) => a.iter().filter_map(|v| v.as_str().or_else(|| v.get("ref").and_then(Value::as_str))).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
        (_, Some(Value::String(s))) if !s.trim().is_empty() => vec![s.trim().to_string()],
        _ => Vec::new(),
    };
    if list.is_empty() {
        return Err(err("missing_ref", "Pass ref (\"U1\") or refs ([\"U1\",\"C3\"])"));
    }
    if list.len() > 256 {
        return Err(err("invalid_refs", "At most 256 refs per call"));
    }
    Ok(list)
}

fn find_refs<'a>(root: &'a Node, refs: &[String]) -> RResult<Vec<&'a Node>> {
    let fps = footprints(root);
    let mut out = Vec::new();
    let mut missing = Vec::new();
    for r in refs {
        match fps.iter().find(|f| fp_ref(f).eq_ignore_ascii_case(r)) {
            Some(f) => {
                if out.iter().any(|o: &&Node| o.start == f.start) {
                    return Err(err("invalid_refs", format!("{r} is listed twice")));
                }
                out.push(*f)
            }
            None => missing.push(r.clone()),
        }
    }
    if !missing.is_empty() {
        let mut all: Vec<String> = fps.iter().map(|f| fp_ref(f)).collect();
        all.sort_by_key(|r| placement::ref_key(r));
        return Err(err("unknown_footprint", format!("No footprint with reference {}", missing.join(", "))).with("missing", json!(missing)).with("candidates", json!(all.into_iter().take(60).collect::<Vec<_>>())));
    }
    Ok(out)
}

fn check_unlocked(fps: &[&Node], args: &Value) -> RResult<()> {
    if args.get("allowLocked") == Some(&json!(true)) {
        return Ok(());
    }
    let locked: Vec<String> = fps.iter().filter(|f| fp_text::is_locked(f)).map(|f| fp_ref(f)).collect();
    if !locked.is_empty() {
        return Err(err("footprint_locked", format!("{} locked; pass allowLocked:true to edit it anyway", locked.join(", "))).with("lockedRefs", json!(locked)));
    }
    Ok(())
}

fn num_arg(args: &Value, key: &str) -> RResult<Option<f64>> {
    match args.get(key) {
        None | Some(Value::Null) => Ok(None),
        Some(v) => pcb::number(Some(v), key, false).map(Some),
    }
}

fn need(args: &Value, key: &str) -> RResult<f64> {
    num_arg(args, key)?.ok_or_else(|| err("missing_arg", format!("{key} is required (mm)")))
}

fn side_arg(args: &Value) -> RResult<bool> {
    match args.get("side").and_then(Value::as_str).map(|s| s.trim().to_ascii_lowercase()) {
        None => Ok(false),
        Some(s) if matches!(s.as_str(), "f.cu" | "f" | "front" | "top") => Ok(false),
        Some(s) if matches!(s.as_str(), "b.cu" | "b" | "back" | "bottom") => Ok(true),
        Some(s) => Err(err("invalid_side", format!("side must be F.Cu or B.Cu, not '{s}'"))),
    }
}

fn with_change(text: &str, root: &Node, edits: Edits, append: &[String]) -> RResult<String> {
    let edited = edits.apply(text).map_err(|e| err("edit_failed", e))?;
    if append.is_empty() {
        return Ok(edited);
    }
    let r = parse_root(&edited)?;
    let _ = root;
    Ok(sxedit::append_top_level(&edited, &r, append))
}

fn stroke(width: f64) -> String {
    format!("(stroke (width {}) (type solid))", n(width))
}

// ---------------------------------------------------------------------------
// Outline
// ---------------------------------------------------------------------------

const GRAPHIC_TAGS: &[(&str, &str)] = &[
    ("gr_line", "line"),
    ("gr_rect", "rect"),
    ("gr_circle", "circle"),
    ("gr_arc", "arc"),
    ("gr_poly", "polygon"),
    ("gr_curve", "bezier"),
    ("gr_text", "text"),
    ("gr_text_box", "textbox"),
    ("dimension", "dimension"),
];

fn graphic_kind(tag: &str) -> Option<&'static str> {
    GRAPHIC_TAGS.iter().find(|(t, _)| *t == tag).map(|(_, k)| *k)
}

/// Board graphics (top level) as rows: uuid, kind, layer and their points.
pub fn board_graphics(root: &Node) -> Vec<Value> {
    root.kids
        .iter()
        .filter_map(|k| {
            let kind = graphic_kind(k.tag()?)?;
            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["pointCount"] = json!(pts.kids_tagged("xy").count());
            }
            if k.tag() == Some("gr_text") {
                row["text"] = json!(k.atom(1));
            }
            Some(row)
        })
        .collect()
}

/// `kicad_board_outline`: a rectangle, a rounded rectangle (true arcs) or a polygon on
/// Edge.Cuts. `replace:true` removes every existing Edge.Cuts graphic first; without it an
/// existing outline is refused (`outline_exists`) unless `append:true`.
pub fn outline(text: &str, args: &Value) -> RResult<Change> {
    let root = parse_root(text)?;
    let board = parse_board(text)?;
    let width = num_arg(args, "lineWidth")?.unwrap_or(0.05);
    if !(width > 0.0 && width < 5.0) {
        return Err(err("invalid_args", "lineWidth must be between 0 and 5 mm"));
    }
    let existing: Vec<&Node> = root.kids.iter().filter(|k| k.tag().map(|t| t.starts_with("gr_")).unwrap_or(false) && k.value_of("layer") == Some("Edge.Cuts")).collect();
    let replace = args.get("replace") == Some(&json!(true));
    if !existing.is_empty() && !replace && args.get("append") != Some(&json!(true)) {
        return Err(err("outline_exists", format!("The board already has {} Edge.Cuts item(s); pass replace:true to replace the outline (or append:true to add a cutout)", existing.len())).with("existing", json!(board_graphics(&root).into_iter().filter(|g| g["layer"] == "Edge.Cuts").collect::<Vec<_>>())));
    }
    let shape = args.get("shape").and_then(Value::as_str).unwrap_or(if args.get("points").is_some() { "polygon" } else if args.get("radius").is_some() { "rounded" } else { "rectangle" });
    let tail = |w: f64| format!("{} (layer \"Edge.Cuts\") (uuid {})", stroke(w), q(&new_uuid()));
    let mut blocks: Vec<String> = Vec::new();
    let (x0, y0, x1, y1);
    match shape {
        "rectangle" | "rect" | "rounded" | "rounded_rectangle" => {
            let w = need(args, "width")?;
            let h = need(args, "height")?;
            if w <= 0.0 || h <= 0.0 {
                return Err(err("invalid_args", "width and height must be positive"));
            }
            let (ox, oy) = match (num_arg(args, "x")?, num_arg(args, "y")?, args.get("center")) {
                (_, _, Some(Value::Array(c))) if c.len() == 2 => (c[0].as_f64().unwrap_or(0.0) - w / 2.0, c[1].as_f64().unwrap_or(0.0) - h / 2.0),
                (Some(x), Some(y), _) => (x, y),
                _ => board.dimensions.as_ref().filter(|_| replace).map(|d| (d.min_x, d.min_y)).unwrap_or((100.0, 100.0)),
            };
            x0 = ox;
            y0 = oy;
            x1 = ox + w;
            y1 = oy + h;
            let r = if shape.starts_with("rounded") { need(args, "radius")? } else { num_arg(args, "radius")?.unwrap_or(0.0) };
            if r < 0.0 || r * 2.0 > w.min(h) + 1e-9 {
                return Err(err("invalid_args", "radius must be between 0 and half the shorter side"));
            }
            if r <= 0.0 {
                blocks.push(format!("(gr_rect (start {} {}) (end {} {}) {} (fill no) (layer \"Edge.Cuts\") (uuid {}))", n(x0), n(y0), n(x1), n(y1), stroke(width), q(&new_uuid())));
            } else {
                let line = |a: (f64, f64), b: (f64, f64)| format!("(gr_line (start {} {}) (end {} {}) {})", n(a.0), n(a.1), n(b.0), n(b.1), tail(width));
                let k = r * (1.0 - std::f64::consts::FRAC_1_SQRT_2);
                let arc = |s: (f64, f64), m: (f64, f64), e: (f64, f64)| format!("(gr_arc (start {} {}) (mid {} {}) (end {} {}) {})", n(s.0), n(s.1), n(m.0), n(m.1), n(e.0), n(e.1), tail(width));
                if w - 2.0 * r > 1e-9 {
                    blocks.push(line((x0 + r, y0), (x1 - r, y0)));
                    blocks.push(line((x1 - r, y1), (x0 + r, y1)));
                }
                if h - 2.0 * r > 1e-9 {
                    blocks.push(line((x1, y0 + r), (x1, y1 - r)));
                    blocks.push(line((x0, y1 - r), (x0, y0 + r)));
                }
                blocks.push(arc((x0, y0 + r), (x0 + k, y0 + k), (x0 + r, y0)));
                blocks.push(arc((x1 - r, y0), (x1 - k, y0 + k), (x1, y0 + r)));
                blocks.push(arc((x1, y1 - r), (x1 - k, y1 - k), (x1 - r, y1)));
                blocks.push(arc((x0 + r, y1), (x0 + k, y1 - k), (x0, y1 - r)));
            }
        }
        "polygon" => {
            let pts = args.get("points").and_then(Value::as_array).ok_or_else(|| err("missing_arg", "points: [[x,y], ...] (3 or more, mm)"))?;
            if pts.len() < 3 || pts.len() > 10_000 {
                return Err(err("invalid_args", "points needs 3 to 10000 vertices"));
            }
            let mut ps = Vec::new();
            for p in pts {
                let a = p.as_array().filter(|a| a.len() == 2).ok_or_else(|| err("invalid_args", "each point is [x, y]"))?;
                ps.push((pcb::number(Some(&a[0]), "x", false)?, pcb::number(Some(&a[1]), "y", false)?));
            }
            x0 = ps.iter().map(|p| p.0).fold(f64::MAX, f64::min);
            y0 = ps.iter().map(|p| p.1).fold(f64::MAX, f64::min);
            x1 = ps.iter().map(|p| p.0).fold(f64::MIN, f64::max);
            y1 = ps.iter().map(|p| p.1).fold(f64::MIN, f64::max);
            let body: Vec<String> = ps.iter().map(|p| format!("(xy {} {})", n(p.0), n(p.1))).collect();
            blocks.push(format!("(gr_poly (pts {}) {} (fill no) (layer \"Edge.Cuts\") (uuid {}))", body.join(" "), stroke(width), q(&new_uuid())));
        }
        other => return Err(err("invalid_args", format!("shape must be rectangle, rounded or polygon, not '{other}'"))),
    }
    let mut e = Edits::new();
    let mut removed = Vec::new();
    if replace {
        for k in &existing {
            e.remove_node(text, k);
            if let Some(u) = k.uuid() {
                removed.push(u.to_string());
            }
        }
    }
    let candidate = with_change(text, &root, e, &blocks)?;
    let after = parse_board(&candidate)?;
    if replace && existing.iter().any(|k| k.uuid().is_none()) {
        return Err(err("item_without_uuid", "An existing Edge.Cuts item has no uuid, so a live commit could not delete it; replace the outline in the closed file or in KiCad"));
    }
    let report = json!({"shape": shape, "box": {"x0": x0, "y0": y0, "x1": x1, "y1": y1, "widthMm": pcb::round6(x1 - x0), "heightMm": pcb::round6(y1 - y0)},
        "itemsAdded": blocks.len(), "itemsRemoved": removed.len(), "removedIds": removed, "outlineAfter": after.dimensions});
    Ok(Change { candidate, remove: removed, create: blocks, copper: false, report, expect: vec![], label: "Adom board outline".into() })
}

// ---------------------------------------------------------------------------
// Copper layer count
// ---------------------------------------------------------------------------

fn inner_name(k: usize) -> String {
    format!("In{k}.Cu")
}

/// `kicad_set_layer_count`: 2, 4, 6 or 8 copper layers. Rewrites the `(layers ...)` table
/// (KiCad 10 ids: F.Cu 0, B.Cu 2, In<k>.Cu 2k+2) and, when the board has one, the physical
/// stack-up (copper and dielectric between F.Cu and B.Cu, keeping the board thickness).
/// Removing an inner layer that carries anything is refused (`layers_in_use`).
pub fn set_layer_count(text: &str, args: &Value) -> RResult<Change> {
    let root = parse_root(text)?;
    let want = args.get("count").or_else(|| args.get("layers")).and_then(Value::as_u64).ok_or_else(|| err("missing_arg", "count: 2, 4, 6 or 8"))? as usize;
    if ![2, 4, 6, 8].contains(&want) {
        return Err(err("invalid_args", "count must be 2, 4, 6 or 8"));
    }
    let layers = root.kid("layers").ok_or_else(|| err("board_unparsable", "the board has no (layers ...) table"))?;
    let copper: Vec<&Node> = layers.kids.iter().skip(1).filter(|l| l.atom(1).map(|n| n.ends_with(".Cu")).unwrap_or(false)).collect();
    let have = copper.len();
    if have == want {
        return Ok(Change { candidate: text.to_string(), report: json!({"countBefore": have, "countAfter": want, "unchanged": true}), label: "Adom layer count".into(), ..Default::default() });
    }
    let keep_inner = want - 2;
    // inner layers that would go
    let gone: Vec<String> = (keep_inner + 1..=have.saturating_sub(2)).map(inner_name).collect();
    if !gone.is_empty() {
        let mut used: Map<String, Value> = Map::new();
        for g in &gone {
            let mut count = 0usize;
            let quoted = q(g);
            root.walk(&mut |nd| {
                if nd.tag() == Some("layer") && nd.atom(1) == Some(g.as_str()) {
                    count += 1;
                }
                if matches!(nd.tag(), Some("layers")) && nd.kids.iter().skip(1).any(|k| k.atom.as_deref() == Some(g.as_str())) && !std::ptr::eq(nd, layers) {
                    count += 1;
                }
            });
            let _ = quoted;
            if count > 0 {
                used.insert(g.clone(), json!(count));
            }
        }
        // the stack-up mentions every layer; it is rewritten, not "use"
        let stack_uses = root.kid("setup").and_then(|s| s.kid("stackup")).map(|s| s.kids_tagged("layer").filter(|l| gone.iter().any(|g| l.atom(1) == Some(g.as_str()))).count()).unwrap_or(0);
        for g in &gone {
            if let Some(v) = used.get_mut(g) {
                let c = v.as_u64().unwrap_or(0) as usize;
                let minus = if stack_uses > 0 { 1 } else { 0 };
                *v = json!(c.saturating_sub(minus));
            }
        }
        used.retain(|_, v| v.as_u64().unwrap_or(0) > 0);
        if !used.is_empty() {
            return Err(err("layers_in_use", format!("{} still carry items; move or delete them before removing the layer", used.keys().cloned().collect::<Vec<_>>().join(", "))).with("itemsPerLayer", Value::Object(used)));
        }
    }
    let mut e = Edits::new();
    // the layers table: keep F.Cu and the surviving inner rows as they are, add the new ones
    let f = copper.iter().find(|l| l.atom(1) == Some("F.Cu")).ok_or_else(|| err("board_unparsable", "no F.Cu in the layers table"))?;
    for l in &copper {
        let name = l.atom(1).unwrap_or("");
        if let Some(k) = name.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|k| k.parse::<usize>().ok()) {
            if k > keep_inner {
                e.remove_node(text, l);
            }
        }
    }
    let last_inner = copper.iter().filter(|l| l.atom(1).map(|n| n.starts_with("In")).unwrap_or(false)).last().copied();
    let mut add = String::new();
    for k in (have.saturating_sub(2) + 1)..=keep_inner {
        add.push_str(&format!("\n\t\t({} {} signal)", 2 * k + 2, q(&inner_name(k))));
    }
    if !add.is_empty() {
        e.insert(last_inner.unwrap_or(f).end, add);
    }
    // the physical stack-up, when present
    let mut stack_note = Value::Null;
    if let Some(stack) = root.kid("setup").and_then(|s| s.kid("stackup")) {
        let rows: Vec<&Node> = stack.kids_tagged("layer").collect();
        let first = rows.iter().position(|l| l.atom(1) == Some("F.Cu"));
        let last = rows.iter().position(|l| l.atom(1) == Some("B.Cu"));
        if let (Some(a), Some(b)) = (first, last) {
            let thick = root.kid("general").and_then(|g| g.kid("thickness")).and_then(|t| t.f64_at(1)).unwrap_or(1.6);
            let cu_of = |name: &str| rows.iter().find(|l| l.atom(1) == Some(name)).and_then(|l| l.kid("thickness")).and_then(|t| t.f64_at(1)).unwrap_or(0.035);
            let masks: f64 = rows.iter().filter(|l| matches!(l.atom(1), Some("F.Mask") | Some("B.Mask"))).filter_map(|l| l.kid("thickness").and_then(|t| t.f64_at(1))).sum();
            let names: Vec<String> = std::iter::once("F.Cu".to_string()).chain((1..=keep_inner).map(inner_name)).chain(std::iter::once("B.Cu".to_string())).collect();
            let cu_total: f64 = names.iter().map(|n| cu_of(n)).sum();
            let diel = ((thick - cu_total - masks) / (want as f64 - 1.0) * 10000.0).round() / 10000.0;
            if diel <= 0.0 {
                return Err(err("stackup_too_thin", format!("A {thick} mm board cannot hold {want} copper layers with these copper thicknesses")));
            }
            let mut s = String::new();
            for (i, name) in names.iter().enumerate() {
                match rows.iter().find(|l| l.atom(1) == Some(name.as_str())) {
                    Some(existing) => s.push_str(existing.src(text)),
                    None => s.push_str(&format!("(layer {}\n\t\t\t\t(type \"copper\")\n\t\t\t\t(thickness 0.035)\n\t\t\t)", q(name))),
                }
                if i + 1 < names.len() {
                    let kind = if i % 2 == 0 { "prepreg" } else { "core" };
                    s.push_str(&format!("\n\t\t\t(layer {}\n\t\t\t\t(type {})\n\t\t\t\t(thickness {})\n\t\t\t\t(material \"FR4\")\n\t\t\t\t(epsilon_r 4.5)\n\t\t\t\t(loss_tangent 0.02)\n\t\t\t)\n\t\t\t", q(&format!("dielectric {}", i + 1)), q(kind), n(diel)));
                }
            }
            e.replace(rows[a].start, rows[b].end, s);
            stack_note = json!({"rewritten": true, "dielectricMm": diel, "boardThicknessMm": thick});
        }
    }
    let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;
    let after = parse_board(&candidate)?;
    let names = pcb::copper_layers(&after);
    if names.len() != want {
        return Err(err("edit_failed", format!("the rewritten board reads back {} copper layers, not {want}", names.len())));
    }
    Ok(Change {
        candidate,
        report: json!({"countBefore": have, "countAfter": want, "copperLayers": names, "removedLayers": gone, "stackup": stack_note}),
        label: "Adom layer count".into(),
        ..Default::default()
    })
}

// ---------------------------------------------------------------------------
// Board graphics: delete, SVG import
// ---------------------------------------------------------------------------

/// `kicad_delete_graphics`: top-level graphics by uuid, or every one matching `layer`
/// and/or `type` (line, rect, circle, arc, polygon, bezier, text, textbox, dimension).
pub fn delete_graphics(text: &str, args: &Value) -> RResult<Change> {
    let root = parse_root(text)?;
    let ids: Vec<String> = args.get("uuids").or_else(|| args.get("itemIds")).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
    let layer = args.get("layer").and_then(Value::as_str);
    let kinds: Vec<String> = match args.get("type") {
        Some(Value::String(s)) => vec![s.clone()],
        Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
        _ => Vec::new(),
    };
    for k in &kinds {
        if !GRAPHIC_TAGS.iter().any(|(_, kk)| kk == k) {
            return Err(err("invalid_args", format!("type '{k}' is not one of line, rect, circle, arc, polygon, bezier, text, textbox, dimension")));
        }
    }
    if ids.is_empty() && layer.is_none() && kinds.is_empty() {
        return Err(err("missing_arg", "Pass uuids, or a layer and/or type filter (dryRun:true lists what would go)"));
    }
    let mut hits: Vec<&Node> = Vec::new();
    for k in &root.kids {
        let Some(kind) = k.tag().and_then(graphic_kind) else { continue };
        let by_id = !ids.is_empty() && k.uuid().map(|u| ids.iter().any(|i| i == u)).unwrap_or(false);
        let by_filter = ids.is_empty() && layer.map(|l| k.value_of("layer") == Some(l) || k.kid("layers").map(|ls| ls.kids.iter().any(|a| a.atom.as_deref() == Some(l))).unwrap_or(false)).unwrap_or(true) && (kinds.is_empty() || kinds.iter().any(|kk| kk == kind));
        if by_id || by_filter {
            hits.push(k);
        }
    }
    if !ids.is_empty() {
        let found: Vec<&str> = hits.iter().filter_map(|h| h.uuid()).collect();
        let missing: Vec<&String> = ids.iter().filter(|i| !found.contains(&i.as_str())).collect();
        if !missing.is_empty() {
            return Err(err("unknown_ids", format!("{} uuid(s) are not board graphics", missing.len())).with("missing", json!(missing)));
        }
    }
    if hits.iter().any(|h| h.uuid().is_none()) {
        return Err(err("item_without_uuid", "A matching graphic has no uuid; a live commit could not address it"));
    }
    let rows: Vec<Value> = board_graphics(&root).into_iter().filter(|g| hits.iter().any(|h| h.uuid() == g["uuid"].as_str())).collect();
    let mut e = Edits::new();
    for h in &hits {
        e.remove_node(text, h);
    }
    let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;
    let copper = hits.iter().any(|h| h.value_of("layer").map(|l| l.ends_with(".Cu")).unwrap_or(false));
    Ok(Change {
        candidate,
        remove: hits.iter().filter_map(|h| h.uuid()).map(str::to_string).collect(),
        copper,
        report: json!({"deleted": rows, "count": rows.len()}),
        label: "Adom delete graphics".into(),
        ..Default::default()
    })
}

/// `kicad_import_svg`: an SVG's filled shapes as filled polygons on one layer.
/// `x`, `y` place the artwork's top-left corner (or its centre with `anchor:"center"`);
/// `width` (mm) scales it to that width, else `scale` multiplies its own size.
pub fn import_svg(text: &str, svg: &str, args: &Value) -> RResult<Change> {
    let root = parse_root(text)?;
    let board = parse_board(text)?;
    let layer = args.get("layer").and_then(Value::as_str).unwrap_or("F.SilkS").to_string();
    let allowed = ["F.SilkS", "B.SilkS", "F.Cu", "B.Cu", "F.Mask", "B.Mask", "F.Fab", "B.Fab", "Dwgs.User", "Cmts.User", "User.1", "User.2", "User.3", "User.4"];
    if !allowed.contains(&layer.as_str()) {
        return Err(err("invalid_layer", format!("layer must be one of {}", allowed.join(", "))));
    }
    let art = crate::svg_import::parse_svg(svg, args.get("toleranceMm").and_then(Value::as_f64).unwrap_or(0.02)).map_err(|e| err("svg_unreadable", e))?;
    if art.rings.is_empty() {
        return Err(err("svg_empty", "The SVG has no filled shape (text must be converted to paths first)").with("skipped", json!(art.skipped)));
    }
    let polys = crate::svg_import::fill_polygons(&art.rings);
    let (mut minx, mut miny, mut maxx, mut maxy) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);
    for p in polys.iter().flatten() {
        minx = minx.min(p.0);
        miny = miny.min(p.1);
        maxx = maxx.max(p.0);
        maxy = maxy.max(p.1);
    }
    let natural_w = (maxx - minx) * art.mm_per_unit;
    let k = match (num_arg(args, "width")?, num_arg(args, "scale")?) {
        (Some(w), _) if w > 0.0 && natural_w > 0.0 => w / natural_w,
        (Some(_), _) => return Err(err("invalid_args", "width must be positive")),
        (None, Some(s)) if s > 0.0 => s,
        (None, Some(_)) => return Err(err("invalid_args", "scale must be positive")),
        (None, None) => 1.0,
    } * art.mm_per_unit;
    let (w, h) = ((maxx - minx) * k, (maxy - miny) * k);
    if w > 1000.0 || h > 1000.0 {
        return Err(err("invalid_args", format!("the artwork would be {w:.1} x {h:.1} mm; pass width or scale")));
    }
    let x = need(args, "x")?;
    let y = need(args, "y")?;
    let center = args.get("anchor").and_then(Value::as_str) == Some("center");
    let (ox, oy) = if center { (x - w / 2.0, y - h / 2.0) } else { (x, y) };
    let mirror = args.get("mirror") == Some(&json!(true)) || layer.starts_with("B.") && args.get("mirror") != Some(&json!(false));
    let net = if layer.ends_with(".Cu") {
        match args.get("net") {
            Some(v) => Some(pcb::resolve_net(&board, Some(v)).map_err(|e| err("unknown_net", e["error"].as_str().unwrap_or("unknown net").to_string()).with("nets", e["nets"].clone()))?),
            None => None,
        }
    } else {
        None
    };
    let mut blocks = Vec::new();
    let mut vertices = 0;
    for p in &polys {
        let pts: Vec<String> = p
            .iter()
            .map(|(px, py)| {
                let lx = (px - minx) * k;
                let lx = if mirror { w - lx } else { lx };
                format!("(xy {} {})", n(ox + lx), n(oy + (py - miny) * k))
            })
            .collect();
        vertices += pts.len();
        let net_text = net.as_ref().filter(|n| !n.name.is_empty()).map(|nr| match nr.format {
            NetFormat::Name => format!(" (net {})", q(&nr.name)),
            NetFormat::Number => format!(" (net {})", nr.number.unwrap_or(0)),
        }).unwrap_or_default();
        blocks.push(format!("(gr_poly (pts {}) (stroke (width 0) (type solid)) (fill yes) (layer {}){net_text} (uuid {}))", pts.join(" "), q(&layer), q(&new_uuid())));
    }
    if vertices > 200_000 {
        return Err(err("svg_too_complex", format!("{vertices} vertices; simplify the artwork or raise toleranceMm")));
    }
    let candidate = sxedit::append_top_level(text, &root, &blocks);
    Ok(Change {
        candidate,
        create: blocks.clone(),
        copper: layer.ends_with(".Cu"),
        report: json!({"layer": layer, "polygons": blocks.len(), "vertices": vertices, "box": {"x0": pcb::round6(ox), "y0": pcb::round6(oy), "x1": pcb::round6(ox + w), "y1": pcb::round6(oy + h)},
            "sizeMm": [pcb::round6(w), pcb::round6(h)], "mirrored": mirror, "skipped": art.skipped}),
        label: "Adom import SVG".into(),
        ..Default::default()
    })
}

// ---------------------------------------------------------------------------
// Footprints: add, mounting hole, flip, delete, edit
// ---------------------------------------------------------------------------

fn ref_split(r: &str) -> (String, Option<u64>) {
    let digits = r.chars().rev().take_while(|c| c.is_ascii_digit()).count();
    let (p, d) = r.split_at(r.len() - digits);
    (p.to_string(), d.parse().ok())
}

fn existing_refs(root: &Node) -> Vec<String> {
    footprints(root).iter().map(|f| fp_ref(f)).collect()
}

fn next_free(prefix: &str, taken: &[String], start: u64) -> u64 {
    let mut k = start.max(1);
    while taken.iter().any(|t| t.eq_ignore_ascii_case(&format!("{prefix}{k}"))) {
        k += 1;
    }
    k
}

/// The references an add makes: `reference` "R" (prefix: the next free numbers) or "R7"
/// (from 7 on; a taken one is refused), `count` of them.
fn plan_refs(root: &Node, reference: &str, count: usize) -> RResult<Vec<String>> {
    let taken = existing_refs(root);
    let (prefix, start) = ref_split(reference);
    if prefix.is_empty() {
        return Err(err("invalid_args", "reference needs a letter prefix (R, C7, H1)"));
    }
    let mut out = Vec::new();
    match start {
        None => {
            let mut k = 1;
            for _ in 0..count {
                k = next_free(&prefix, &taken, k);
                out.push(format!("{prefix}{k}"));
                k += 1;
            }
        }
        Some(s) => {
            for i in 0..count as u64 {
                let r = format!("{prefix}{}", s + i);
                if taken.iter().any(|t| t.eq_ignore_ascii_case(&r)) {
                    return Err(err("duplicate_reference", format!("{r} is already on the board")).with("nextFree", json!(format!("{prefix}{}", next_free(&prefix, &taken, 1)))));
                }
                out.push(r);
            }
        }
    }
    Ok(out)
}

fn pad_net_text(board: &Board, net: &str) -> RResult<String> {
    let nr = pcb::resolve_net(board, Some(&json!(net))).map_err(|e| err("unknown_net", e["error"].as_str().unwrap_or("unknown net").to_string()).with("nets", e["nets"].clone()))?;
    Ok(match nr.format {
        NetFormat::Name => format!("(net {})", q(&nr.name)),
        NetFormat::Number => format!("(net {} {})", nr.number.unwrap_or(0), q(&nr.name)),
    })
}

/// Assign nets to pads by number: `nets` maps pad number to net name.
fn set_pad_nets(block: &str, board: &Board, nets: &Map<String, Value>) -> RResult<String> {
    let fp = sxedit::parse(block).map_err(|e| err("edit_failed", e))?;
    let mut e = Edits::new();
    for (pad_no, net) in nets {
        let net = net.as_str().ok_or_else(|| err("invalid_args", "nets maps pad numbers to net names"))?;
        let pads: Vec<&Node> = fp.kids_tagged("pad").filter(|p| p.atom(1) == Some(pad_no.as_str())).collect();
        if pads.is_empty() {
            return Err(err("unknown_pad", format!("the footprint has no pad {pad_no}")));
        }
        let t = pad_net_text(board, net)?;
        for p in pads {
            match p.kid("net") {
                Some(old) => e.replace_node(old, t.clone()),
                None => e.insert(p.kid("uuid").map(|u| u.start).unwrap_or(p.end - 1), format!("{t} ")),
            }
        }
    }
    e.apply(block).map_err(|x| err("edit_failed", x))
}

fn expect_of(block: &str) -> Expect {
    let fp = sxedit::parse(block).unwrap_or_else(|_| Node { start: 0, end: 0, atom: None, quoted: false, kids: vec![] });
    Expect { reference: fp_ref(&fp), present: true, pose: Some(fp_text::pose(&fp)), side: Some(fp_text::side(&fp)) }
}

/// `kicad_add_footprint`: a library footprint (its `.kicad_mod` text in `lib_text`, named
/// `lib_id`) or a copy of `duplicateOf`, at `x`, `y`, `rotation`, `side`, with `reference`
/// and `value`; `count` with `pitch` [dx, dy] makes a row or grid of copies.
pub fn add_footprint(text: &str, lib: Option<(&str, &str)>, args: &Value) -> RResult<Change> {
    let root = parse_root(text)?;
    let board = parse_board(text)?;
    let copper = fp_text::copper_count(&root);
    let x = need(args, "x")?;
    let y = need(args, "y")?;
    let rot = num_arg(args, "rotation")?.unwrap_or(0.0);
    let back = side_arg(args)?;
    let count = args.get("count").and_then(Value::as_u64).unwrap_or(1) as usize;
    if count == 0 || count > 100 {
        return Err(err("invalid_args", "count must be 1..100"));
    }
    let pitch = match args.get("pitch") {
        Some(Value::Array(a)) if a.len() == 2 => (pcb::number(Some(&a[0]), "pitch x", false)?, pcb::number(Some(&a[1]), "pitch y", false)?),
        Some(Value::Number(v)) => (v.as_f64().unwrap_or(0.0), 0.0),
        None | Some(Value::Null) if count == 1 => (0.0, 0.0),
        _ => return Err(err("missing_arg", "count > 1 needs pitch: [dx, dy] in mm")),
    };
    let cols = args.get("columns").and_then(Value::as_u64).unwrap_or(count as u64).max(1) as usize;
    let value = args.get("value").and_then(Value::as_str);
    let nets = args.get("nets").and_then(Value::as_object);
    if nets.is_some() && count > 1 {
        return Err(err("invalid_args", "nets is for a single footprint (count 1)"));
    }
    let (source, refs) = match (lib, args.get("duplicateOf").and_then(Value::as_str)) {
        (_, Some(dup)) => {
            let src = find_refs(&root, &[dup.to_string()])?[0];
            let reference = args.get("reference").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| ref_split(&fp_ref(src)).0);
            (Err(src), plan_refs(&root, &reference, count)?)
        }
        (Some(l), None) => {
            let reference = args.get("reference").and_then(Value::as_str).ok_or_else(|| err("missing_arg", "reference is required (\"R\" for the next free number, or \"R7\")"))?;
            (Ok(l), plan_refs(&root, reference, count)?)
        }
        (None, None) => return Err(err("missing_arg", "Pass libraryId (\"Resistor_SMD:R_0603_1608Metric\"), footprintPath, or duplicateOf")),
    };
    let mut blocks = Vec::new();
    for (i, r) in refs.iter().enumerate() {
        let (cx, cy) = (x + pitch.0 * (i % cols) as f64, y + pitch.1 * (i / cols) as f64);
        let block = match &source {
            Ok((lib_text, lib_id)) => fp_text::place_library(lib_text, lib_id, cx, cy, rot, back, r, value, copper).map_err(|e| err("footprint_unusable", e))?,
            Err(src) => {
                let mut b = fp_text::fresh_uuids(src.src(text)).map_err(|e| err("edit_failed", e))?;
                if args.get("keepNets") != Some(&json!(true)) {
                    b = fp_text::strip_nets(&b).map_err(|e| err("edit_failed", e))?;
                }
                b = fp_text::strip_symbol_link(&b).map_err(|e| err("edit_failed", e))?;
                b = fp_text::set_property(&b, "Reference", r).map_err(|e| err("edit_failed", e))?;
                if let Some(v) = value {
                    b = fp_text::set_property(&b, "Value", v).map_err(|e| err("edit_failed", e))?;
                }
                let src_back = fp_text::side(src) == "B.Cu";
                let want_back = if args.get("side").is_some() { back } else { src_back };
                if want_back != src_back {
                    b = fp_text::flip_block(&b, true, copper).map_err(|e| err("flip_unsupported", e))?;
                }
                let r = if args.get("rotation").is_some() { rot } else { fp_text::pose(src).2 };
                fp_text::move_block(&b, cx, cy, r).map_err(|e| err("edit_failed", e))?
            }
        };
        let block = match nets {
            Some(m) => set_pad_nets(&block, &board, m)?,
            None => block,
        };
        blocks.push(block);
    }
    let candidate = sxedit::append_top_level(text, &root, &blocks);
    let after = parse_board(&candidate)?;
    let added: Vec<Value> = refs.iter().filter_map(|r| after.footprints.iter().find(|f| &f.reference == r)).map(|f| json!({"ref": f.reference, "footprint": f.footprint, "value": f.value, "uuid": f.uuid, "at": f.position, "side": f.layer, "pads": f.pad_count})).collect();
    if added.len() != refs.len() {
        return Err(err("edit_failed", "the new footprints did not read back"));
    }
    Ok(Change {
        expect: blocks.iter().map(|b| expect_of(b)).collect(),
        create: blocks,
        candidate,
        copper: true,
        report: json!({"added": added, "count": refs.len()}),
        label: format!("Adom add {}", refs.join(", ")),
        ..Default::default()
    })
}

/// Stock KiCad MountingHole library names for a drill, when one exists.
pub fn mounting_hole_lib_id(drill: f64, plated: bool) -> Option<String> {
    let table = [(2.2, "M2"), (2.7, "M2.5"), (3.2, "M3"), (3.7, "M3.5"), (4.3, "M4"), (5.3, "M5"), (6.4, "M6"), (8.4, "M8")];
    let (d, m) = table.iter().find(|(d, _)| (d - drill).abs() < 1e-6)?;
    Some(format!("MountingHole:MountingHole_{}mm_{m}{}", n(*d), if plated { "_Pad" } else { "" }))
}

fn generated_hole(drill: f64, pad: f64, plated: bool, reference: &str) -> String {
    let r = (if plated { pad } else { drill }) / 2.0;
    let court = r + 0.25;
    let pad_text = if plated {
        format!("(pad \"1\" thru_hole circle (at 0 0) (size {p} {p}) (drill {d}) (layers \"*.Cu\" \"*.Mask\") (remove_unused_layers no) (uuid {u}))", p = n(pad), d = n(drill), u = q(&new_uuid()))
    } else {
        format!("(pad \"\" np_thru_hole circle (at 0 0) (size {d} {d}) (drill {d}) (layers \"*.Cu\" \"*.Mask\") (uuid {u}))", d = n(drill), u = q(&new_uuid()))
    };
    format!(
        "(footprint {name}\n\t\t(layer \"F.Cu\")\n\t\t(uuid {u})\n\t\t(at 0 0)\n\t\t(descr \"Mounting hole, {kind}, drill {d} mm (generated by the Adom KiCad Bridge)\")\n\t\t(tags \"mounting hole\")\n\t\t(property \"Reference\" {rf} (at 0 {ry}) (layer \"F.SilkS\") (uuid {u1}) (effects (font (size 1 1) (thickness 0.15))))\n\t\t(property \"Value\" \"MountingHole\" (at 0 {vy}) (layer \"F.Fab\") (uuid {u2}) (effects (font (size 1 1) (thickness 0.15))))\n\t\t(attr exclude_from_pos_files exclude_from_bom)\n\t\t(fp_circle (center 0 0) (end {r} 0) (stroke (width 0.15) (type solid)) (fill no) (layer \"Cmts.User\") (uuid {u3}))\n\t\t(fp_circle (center 0 0) (end {c} 0) (stroke (width 0.05) (type solid)) (fill no) (layer \"F.CrtYd\") (uuid {u4}))\n\t\t{pad_text}\n\t)",
        name = q(&format!("MountingHole:MountingHole_{}mm{}", n(drill), if plated { "_Pad" } else { "" })),
        u = q(&new_uuid()),
        kind = if plated { "plated" } else { "NPTH" },
        d = n(drill),
        rf = q(reference),
        ry = n(-(court + 1.0)),
        vy = n(court + 1.0),
        u1 = q(&new_uuid()),
        u2 = q(&new_uuid()),
        u3 = q(&new_uuid()),
        u4 = q(&new_uuid()),
        r = n(r),
        c = n(court),
    )
}

/// `kicad_add_mounting_hole`: NPTH (default) or plated (`plated:true`, `padDiameter`,
/// optional `net`) at `x`, `y`. `lib_text` is the stock MountingHole footprint when the
/// caller found one for this drill (`mounting_hole_lib_id`); else one is generated.
pub fn add_mounting_hole(text: &str, lib: Option<(&str, &str)>, args: &Value) -> RResult<Change> {
    let root = parse_root(text)?;
    let board = parse_board(text)?;
    let x = need(args, "x")?;
    let y = need(args, "y")?;
    let drill = need(args, "drill")?;
    if !(0.1..=50.0).contains(&drill) {
        return Err(err("invalid_args", "drill must be 0.1 to 50 mm"));
    }
    let plated = args.get("plated") == Some(&json!(true)) || args.get("net").is_some();
    let pad = num_arg(args, "padDiameter")?.unwrap_or(if plated { pcb::round6(drill * 2.0) } else { drill });
    if plated && pad <= drill {
        return Err(err("invalid_args", "padDiameter must exceed the drill for a plated hole"));
    }
    let reference = args.get("reference").and_then(Value::as_str).unwrap_or("H");
    let r = plan_refs(&root, reference, 1)?.remove(0);
    let use_lib = lib.filter(|_| args.get("padDiameter").is_none() || !plated);
    let (block, source) = match use_lib {
        Some((lt, id)) => (fp_text::place_library(lt, id, x, y, 0.0, false, &r, None, 2).map_err(|e| err("footprint_unusable", e))?, id.to_string()),
        None => (fp_text::move_block(&generated_hole(drill, pad, plated, &r), x, y, 0.0).map_err(|e| err("edit_failed", e))?, "generated".to_string()),
    };
    let block = match (plated, args.get("net").and_then(Value::as_str)) {
        (true, Some(net)) => {
            let mut m = Map::new();
            m.insert("1".into(), json!(net));
            set_pad_nets(&block, &board, &m)?
        }
        _ => block,
    };
    let candidate = sxedit::append_top_level(text, &root, &[block.clone()]);
    Ok(Change {
        expect: vec![expect_of(&block)],
        create: vec![block],
        candidate,
        copper: plated,
        report: json!({"ref": r, "x": x, "y": y, "drill": drill, "plated": plated, "padDiameter": if plated { json!(pad) } else { Value::Null }, "net": args.get("net"), "footprint": source}),
        label: format!("Adom mounting hole {r}"),
        ..Default::default()
    })
}

/// Replace footprint blocks: the candidate keeps each footprint's text position (and its
/// UUIDs) for a file, the live create list carries fresh-UUID copies.
fn replace_blocks(text: &str, root: &Node, rows: &[(&Node, String)], label: String, copper: bool, report: Value) -> RResult<Change> {
    let mut e = Edits::new();
    let mut remove = Vec::new();
    let mut create = Vec::new();
    let mut expect = Vec::new();
    for (old, new_block) in rows {
        e.replace_node(old, new_block.clone());
        remove.push(old.uuid().ok_or_else(|| err("footprint_without_uuid", format!("{} has no uuid", fp_ref(old))))?.to_string());
        create.push(fp_text::fresh_uuids(new_block).map_err(|x| err("edit_failed", x))?);
        expect.push(expect_of(new_block));
    }
    let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;
    parse_board(&candidate)?;
    let _ = root;
    Ok(Change { candidate, remove, create, copper, report, expect, label })
}

/// `kicad_flip_footprint`: move footprints to the other side, mirrored about their anchors.
pub fn flip_footprints(text: &str, args: &Value) -> RResult<Change> {
    let root = parse_root(text)?;
    let refs = refs_arg(args)?;
    let fps = find_refs(&root, &refs)?;
    check_unlocked(&fps, args)?;
    let left_right = match args.get("direction").and_then(Value::as_str).unwrap_or("left_right") {
        "left_right" | "leftRight" | "lr" => true,
        "top_bottom" | "topBottom" | "tb" => false,
        o => return Err(err("invalid_args", format!("direction must be left_right or top_bottom, not '{o}'"))),
    };
    let copper = fp_text::copper_count(&root);
    let mut rows = Vec::new();
    let mut moved = Vec::new();
    for f in &fps {
        let block = fp_text::flip_block(f.src(text), left_right, copper).map_err(|e| err("flip_unsupported", format!("{}: {e}", fp_ref(f))).with("ref", json!(fp_ref(f))))?;
        let nf = sxedit::parse(&block).map_err(|e| err("edit_failed", e))?;
        moved.push(json!({"ref": fp_ref(f), "from": fp_text::side(f), "to": fp_text::side(&nf), "rotationBefore": fp_text::pose(f).2, "rotationAfter": fp_text::pose(&nf).2}));
        rows.push((*f, block));
    }
    replace_blocks(text, &root, &rows, format!("Adom flip {}", refs.join(", ")), true, json!({"flipped": moved, "direction": if left_right { "left_right" } else { "top_bottom" }}))
}

/// `kicad_delete_footprint`: remove footprints by reference.
pub fn delete_footprints(text: &str, args: &Value) -> RResult<Change> {
    let root = parse_root(text)?;
    let board = parse_board(text)?;
    let refs = refs_arg(args)?;
    let fps = find_refs(&root, &refs)?;
    check_unlocked(&fps, args)?;
    let mut e = Edits::new();
    let mut remove = Vec::new();
    let mut rows = Vec::new();
    for f in &fps {
        let r = fp_ref(f);
        e.remove_node(text, f);
        remove.push(f.uuid().ok_or_else(|| err("footprint_without_uuid", format!("{r} has no uuid")))?.to_string());
        let bf = board.footprints.iter().find(|b| b.reference == r);
        let mut nets: Vec<String> = bf.map(|b| b.pads.iter().filter_map(|p| p.net_name.clone()).filter(|n| !n.is_empty()).collect()).unwrap_or_default();
        nets.sort();
        nets.dedup();
        rows.push(json!({"ref": r, "footprint": f.atom(1), "uuid": f.uuid(), "nets": nets}));
    }
    let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;
    Ok(Change {
        candidate,
        remove,
        copper: true,
        expect: refs.iter().map(|r| Expect { reference: fp_ref(fps.iter().find(|f| fp_ref(f).eq_ignore_ascii_case(r)).unwrap()), present: false, pose: None, side: None }).collect(),
        report: json!({"deleted": rows, "count": rows.len(), "note": "Tracks and vias that met these pads stay on the board; kicad_routing_state shows what is left dangling."}),
        label: format!("Adom delete {}", refs.join(", ")),
        ..Default::default()
    })
}

/// The edit `kicad_edit_footprint` makes to one footprint, from the arguments.
pub fn edit_one(block: &str, args: &Value, taken: &[String]) -> RResult<(String, Vec<String>)> {
    let mut b = block.to_string();
    let mut changed = Vec::new();
    let fe = |e: String| err("edit_failed", e);
    if let Some(v) = args.get("value") {
        let v = v.as_str().ok_or_else(|| err("invalid_args", "value is a string"))?;
        b = fp_text::set_property(&b, "Value", v).map_err(fe)?;
        changed.push("value".into());
    }
    if let Some(r) = args.get("newReference").or_else(|| args.get("reference")) {
        let r = r.as_str().filter(|s| !s.trim().is_empty()).ok_or_else(|| err("invalid_args", "newReference is a non-empty string"))?;
        if taken.iter().any(|t| t.eq_ignore_ascii_case(r)) {
            return Err(err("duplicate_reference", format!("{r} is already on the board")));
        }
        b = fp_text::set_property(&b, "Reference", r).map_err(fe)?;
        changed.push("reference".into());
    }
    if let Some(fields) = args.get("fields") {
        let m = fields.as_object().ok_or_else(|| err("invalid_args", "fields maps names to values (null removes a user field)"))?;
        for (k, v) in m {
            match v {
                Value::Null => b = fp_text::remove_property(&b, k).map_err(|e| err("invalid_args", e))?,
                Value::String(s) => b = fp_text::set_property(&b, k, s).map_err(fe)?,
                other => b = fp_text::set_property(&b, k, &other.to_string()).map_err(fe)?,
            }
            changed.push(format!("field {k}"));
        }
    }
    let mut attrs = args.get("attributes").and_then(Value::as_object).cloned().unwrap_or_default();
    for (k, _) in fp_text::ATTR_FLAGS {
        if let Some(v) = args.get(*k) {
            attrs.insert(k.to_string(), v.clone());
        }
    }
    if !attrs.is_empty() {
        b = fp_text::set_attrs(&b, &attrs).map_err(|e| err("invalid_args", e))?;
        changed.push("attributes".into());
    }
    if let Some(l) = args.get("locked") {
        let l = l.as_bool().ok_or_else(|| err("invalid_args", "locked is true or false"))?;
        b = fp_text::set_locked(&b, l).map_err(fe)?;
        changed.push("locked".into());
    }
    if changed.is_empty() {
        return Err(err("missing_arg", "Nothing to change: pass value, newReference, fields, attributes (dnp, excludeFromBom, excludeFromPosFiles, ...) or locked"));
    }
    Ok((b, changed))
}

/// What a footprint looks like in a reply: reference, value, fields, attributes, lock.
pub fn footprint_info(fp: &Node) -> Value {
    let props: Map<String, Value> = fp_text::properties(fp).into_iter().map(|(k, v)| (k, json!(v))).collect();
    json!({"ref": fp_ref(fp), "footprint": fp.atom(1), "uuid": fp.uuid(), "side": fp_text::side(fp), "at": fp_text::pose(fp), "fields": props, "attributes": fp_text::attrs(fp), "locked": fp_text::is_locked(fp)})
}

/// `kicad_edit_footprint` (file form): value, reference, fields, attributes and lock.
/// The edit each footprint gets: `edits: [{ref, ...}]` gives every part its own (the EDA Bridge
/// Standard's edit_parts); otherwise `ref`/`refs` share the call's own arguments.
pub fn edits_by_ref(args: &Value) -> RResult<Vec<(String, Value)>> {
    if let Some(edits) = args.get("edits") {
        let list = edits.as_array().filter(|a| !a.is_empty()).ok_or_else(|| err("invalid_args", "edits is a list of {ref, ...the fields to change}"))?;
        let mut out: Vec<(String, Value)> = Vec::new();
        for (i, e) in list.iter().enumerate() {
            let r = e.get("ref").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).ok_or_else(|| err("missing_ref", format!("edits[{i}] has no ref")))?;
            if out.iter().any(|(x, _)| x.eq_ignore_ascii_case(r)) {
                return Err(err("invalid_refs", format!("{r} is edited twice; merge its edits into one entry")));
            }
            out.push((r.to_string(), e.clone()));
        }
        if out.len() > 256 {
            return Err(err("invalid_refs", "At most 256 refs per call"));
        }
        return Ok(out);
    }
    let refs = refs_arg(args)?;
    if refs.len() > 1 && args.get("newReference").or_else(|| args.get("reference")).is_some() {
        return Err(err("invalid_args", "newReference renames one footprint; pass a single ref"));
    }
    Ok(refs.into_iter().map(|r| (r, args.clone())).collect())
}

pub fn edit_footprints(text: &str, args: &Value) -> RResult<Change> {
    let root = parse_root(text)?;
    let edits = edits_by_ref(args)?;
    let refs: Vec<String> = edits.iter().map(|(r, _)| r.clone()).collect();
    let fps = find_refs(&root, &refs)?;
    let taken = existing_refs(&root);
    let mut rows = Vec::new();
    let mut report = Vec::new();
    for f in &fps {
        let own = edits.iter().find(|(r, _)| r.eq_ignore_ascii_case(&fp_ref(f))).map(|(_, a)| a).unwrap_or(args);
        let (b, changed) = edit_one(f.src(text), own, &taken)?;
        let nf = sxedit::parse(&b).map_err(|e| err("edit_failed", e))?;
        report.push(json!({"ref": fp_ref(f), "changed": changed, "before": footprint_info(f), "after": footprint_info(&nf)}));
        rows.push((*f, b));
    }
    let mut c = replace_blocks(text, &root, &rows, format!("Adom edit {}", refs.join(", ")), false, json!({"edited": report}))?;
    c.copper = edits.iter().any(|(_, a)| a.get("attributes").is_some() || fp_text::ATTR_FLAGS.iter().any(|(k, _)| a.get(*k).is_some()));
    Ok(c)
}

// ---------------------------------------------------------------------------
// Alignment
// ---------------------------------------------------------------------------

/// `kicad_align_footprints`: the moves that line `refs` up on one edge or centre line
/// (`mode` left, right, top, bottom, centerX, centerY) or spread them evenly
/// (distributeX, distributeY), by courtyard box (default) or by anchor (`by:"anchor"`).
/// `to` fixes the target coordinate; otherwise the group's own extreme (or mean centre).
/// Returns `refs: [{ref, x, y}]` for kicad_move_footprint and the report.
pub fn align_moves(board: &Board, args: &Value) -> RResult<(Vec<Value>, Value)> {
    let refs = refs_arg(args)?;
    if refs.len() < 2 {
        return Err(err("invalid_refs", "Align at least two footprints"));
    }
    let mode = args.get("mode").and_then(Value::as_str).ok_or_else(|| err("missing_arg", "mode: left, right, top, bottom, centerX, centerY, distributeX or distributeY"))?;
    let by_anchor = args.get("by").and_then(Value::as_str) == Some("anchor");
    let places = placement::placements(board);
    let mut rows = Vec::new();
    for r in &refs {
        let p = places.iter().find(|p| p.reference.eq_ignore_ascii_case(r)).ok_or_else(|| err("unknown_footprint", format!("No footprint with reference '{r}'")))?;
        rows.push(p);
    }
    let bx = |p: &placement::Placement| if by_anchor { (p.x, p.y, p.x, p.y) } else { (p.courtyard.min_x, p.courtyard.min_y, p.courtyard.max_x, p.courtyard.max_y) };
    let to = num_arg(args, "to")?;
    let mut moves: Vec<(String, f64, f64)> = Vec::new();
    let pick = |f: &dyn Fn(&(f64, f64, f64, f64)) -> f64, agg: &dyn Fn(f64, f64) -> f64, init: f64| rows.iter().map(|p| f(&bx(p))).fold(init, agg);
    match mode {
        "left" | "right" | "top" | "bottom" | "centerX" | "centerY" => {
            let target = match mode {
                "left" => to.unwrap_or_else(|| pick(&|b| b.0, &f64::min, f64::MAX)),
                "right" => to.unwrap_or_else(|| pick(&|b| b.2, &f64::max, f64::MIN)),
                "top" => to.unwrap_or_else(|| pick(&|b| b.1, &f64::min, f64::MAX)),
                "bottom" => to.unwrap_or_else(|| pick(&|b| b.3, &f64::max, f64::MIN)),
                "centerX" => to.unwrap_or_else(|| rows.iter().map(|p| (bx(p).0 + bx(p).2) / 2.0).sum::<f64>() / rows.len() as f64),
                _ => to.unwrap_or_else(|| rows.iter().map(|p| (bx(p).1 + bx(p).3) / 2.0).sum::<f64>() / rows.len() as f64),
            };
            for p in &rows {
                let b = bx(p);
                let (dx, dy) = match mode {
                    "left" => (target - b.0, 0.0),
                    "right" => (target - b.2, 0.0),
                    "top" => (0.0, target - b.1),
                    "bottom" => (0.0, target - b.3),
                    "centerX" => (target - (b.0 + b.2) / 2.0, 0.0),
                    _ => (0.0, target - (b.1 + b.3) / 2.0),
                };
                moves.push((p.reference.clone(), pcb::round6(p.x + dx), pcb::round6(p.y + dy)));
            }
        }
        "distributeX" | "distributeY" => {
            if rows.len() < 3 {
                return Err(err("invalid_refs", "Distribute needs at least three footprints"));
            }
            let x_axis = mode == "distributeX";
            let lo = |b: (f64, f64, f64, f64)| if x_axis { b.0 } else { b.1 };
            let hi = |b: (f64, f64, f64, f64)| if x_axis { b.2 } else { b.3 };
            let mut order: Vec<&placement::Placement> = rows.clone();
            order.sort_by(|a, b| (lo(bx(a)) + hi(bx(a))).partial_cmp(&(lo(bx(b)) + hi(bx(b)))).unwrap_or(std::cmp::Ordering::Equal));
            let first = lo(bx(order[0]));
            let last = hi(bx(order[order.len() - 1]));
            let sizes: f64 = order.iter().map(|p| hi(bx(p)) - lo(bx(p))).sum();
            let gap = match num_arg(args, "gap")? {
                Some(g) => g,
                None => (last - first - sizes) / (order.len() as f64 - 1.0),
            };
            let mut cursor = first;
            for p in &order {
                let b = bx(p);
                let d = cursor - lo(b);
                let (dx, dy) = if x_axis { (d, 0.0) } else { (0.0, d) };
                moves.push((p.reference.clone(), pcb::round6(p.x + dx), pcb::round6(p.y + dy)));
                cursor += hi(b) - lo(b) + gap;
            }
        }
        other => return Err(err("invalid_args", format!("mode '{other}' is not left, right, top, bottom, centerX, centerY, distributeX or distributeY"))),
    }
    let out: Vec<Value> = moves.iter().map(|(r, x, y)| json!({"ref": r, "x": x, "y": y})).collect();
    let moving = moves.iter().filter(|(r, x, y)| rows.iter().any(|p| &p.reference == r && ((p.x - x).abs() > 1e-6 || (p.y - y).abs() > 1e-6))).count();
    Ok((out.clone(), json!({"mode": mode, "by": if by_anchor { "anchor" } else { "courtyard" }, "targets": out, "moving": moving})))
}

// ---------------------------------------------------------------------------
// Update from library
// ---------------------------------------------------------------------------

fn pad_sig(p: &Node) -> String {
    let at = p.kid("at");
    let f = |v: Option<f64>| format!("{:.4}", v.unwrap_or(0.0));
    let layers: Vec<&str> = p.kid("layers").map(|l| l.kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect()).unwrap_or_default();
    format!(
        "{} {} at({},{},{}) size({},{}) drill({}) layers({})",
        p.atom(2).unwrap_or(""),
        p.atom(3).unwrap_or(""),
        f(at.and_then(|a| a.f64_at(1))),
        f(at.and_then(|a| a.f64_at(2))),
        f(at.and_then(|a| a.f64_at(3)).map(fp_text::norm_deg)),
        f(p.kid("size").and_then(|s| s.f64_at(1))),
        f(p.kid("size").and_then(|s| s.f64_at(2))),
        p.kid("drill").map(|d| d.kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect::<Vec<_>>().join(" ")).unwrap_or_default(),
        layers.join(" ")
    )
}

fn graphic_sig(fp: &Node) -> Vec<String> {
    let mut v: Vec<String> = fp_text::graphics(fp).iter().filter(|g| g["kind"] != "text").map(|g| {
        let mut g = g.clone();
        g.as_object_mut().map(|o| o.remove("uuid"));
        g.to_string()
    }).collect();
    v.sort();
    v
}

/// The updated block for one placed footprint: the library's geometry at the board pose,
/// with the board's fields, symbol link, pad nets and pin data, lock state, uuid and the
/// DNP / BOM / position-file flags kept.
pub fn updated_block(text: &str, fp: &Node, lib_text: &str, copper: usize) -> RResult<String> {
    updated_block_as(text, fp, lib_text, fp.atom(1).unwrap_or(""), copper)
}

/// `updated_block` with another library footprint (`lib_id`, its `.kicad_mod` in `lib_text`) in the part's place: the
/// swap. Pads keep their nets by number; a pad number the old footprint did not have gets none.
pub fn updated_block_as(text: &str, fp: &Node, lib_text: &str, lib_id: &str, copper: usize) -> RResult<String> {
    let (x, y, rot) = fp_text::pose(fp);
    let back = fp_text::side(fp) == "B.Cu";
    let fresh = fp_text::place_library(lib_text, lib_id, x, y, rot, back, &fp_ref(fp), None, copper).map_err(|e| err("footprint_unusable", e))?;
    let nf = sxedit::parse(&fresh).map_err(|e| err("edit_failed", e))?;
    let mut e = Edits::new();
    // fields: the board's own property blocks, verbatim
    let mut extra = String::new();
    for p in fp.kids_tagged("property") {
        let name = p.atom(1).unwrap_or("");
        match nf.kids_tagged("property").find(|np| np.atom(1) == Some(name)) {
            Some(np) => e.replace_node(np, p.src(text)),
            None => {
                extra.push_str("\n\t\t");
                extra.push_str(p.src(text));
            }
        }
    }
    for t in ["path", "sheetname", "sheetfile", "units"] {
        if let Some(k) = fp.kid(t) {
            extra.push_str("\n\t\t");
            extra.push_str(k.src(text));
        }
    }
    if let Some(last) = nf.kids_tagged("property").last() {
        e.insert(last.end, extra);
    }
    if let (Some(old), Some(new)) = (fp.uuid(), nf.kid("uuid")) {
        e.replace_node(new, format!("(uuid {})", q(old)));
    }
    // pad nets and pin data by pad number
    for np in nf.kids_tagged("pad") {
        let num = np.atom(1).unwrap_or("");
        if num.is_empty() {
            continue;
        }
        let Some(op) = fp.kids_tagged("pad").find(|op| op.atom(1) == Some(num)) else { continue };
        let mut add = String::new();
        for t in ["net", "pinfunction", "pintype"] {
            if let Some(k) = op.kid(t) {
                if np.kid(t).is_none() {
                    add.push_str(k.src(text));
                    add.push(' ');
                }
            }
        }
        if !add.is_empty() {
            e.insert(np.kid("uuid").map(|u| u.start).unwrap_or(np.end - 1), add);
        }
    }
    let mut b = e.apply(&fresh).map_err(|x| err("edit_failed", x))?;
    let old_attrs = fp_text::attrs(fp);
    let mut keep = Map::new();
    for k in ["dnp", "excludeFromBom", "excludeFromPosFiles"] {
        if old_attrs[k] == json!(true) {
            keep.insert(k.into(), json!(true));
        }
    }
    if !keep.is_empty() {
        b = fp_text::set_attrs(&b, &keep).map_err(|x| err("edit_failed", x))?;
    }
    if fp_text::is_locked(fp) {
        b = fp_text::set_locked(&b, true).map_err(|x| err("edit_failed", x))?;
    }
    Ok(b)
}

/// A library id "Lib:Name" as (library nickname, footprint name).
fn lib_name(id: &str) -> (String, String) {
    match id.split_once(':') { Some((l, n)) => (l.to_string(), n.to_string()), None => (String::new(), id.to_string()) }
}

/// Which parts of a pad's signature differ (`pad_sig`): type, shape, position, rotation, size, drill, layers.
fn pad_changes(a: &str, b: &str) -> Vec<String> {
    fn parts(s: &str) -> Vec<(&'static str, String)> {
        let mut words = s.split_whitespace();
        let (t, sh) = (words.next().unwrap_or("").to_string(), words.next().unwrap_or("").to_string());
        let inner = |k: &str| s.find(&format!("{k}(")).map(|i| { let r = &s[i + k.len() + 1..]; r[..r.find(')').unwrap_or(r.len())].to_string() }).unwrap_or_default();
        let at = inner("at");
        let mut xyz = at.split(',');
        let (x, y, r) = (xyz.next().unwrap_or("").to_string(), xyz.next().unwrap_or("").to_string(), xyz.next().unwrap_or("").to_string());
        vec![("type", t), ("shape", sh), ("position", format!("{x},{y}")), ("rotation", r), ("size", inner("size")), ("drill", inner("drill")), ("layers", inner("layers"))]
    }
    let (pa, pb) = (parts(a), parts(b));
    pa.iter().zip(&pb).filter(|(x, y)| x.1 != y.1).map(|(x, _)| x.0.to_string()).collect()
}

/// The differences between a placed footprint and its library version, in words.
pub fn footprint_diff(old: &Node, new: &Node) -> Value {
    let pads = |f: &Node| -> Vec<(String, String)> { f.kids_tagged("pad").map(|p| (p.atom(1).unwrap_or("").to_string(), pad_sig(p))).collect() };
    let (po, pn) = (pads(old), pads(new));
    let nums = |v: &Vec<(String, String)>| { let mut s: Vec<String> = v.iter().map(|p| p.0.clone()).collect(); s.sort(); s.dedup(); s };
    let (no, nn) = (nums(&po), nums(&pn));
    let added: Vec<&String> = nn.iter().filter(|k| !no.contains(k)).collect();
    let removed: Vec<&String> = no.iter().filter(|k| !nn.contains(k)).collect();
    let mut changed_pads = Vec::new();
    for k in nn.iter().filter(|k| no.contains(k)) {
        let a: Vec<&String> = po.iter().filter(|p| &p.0 == k).map(|p| &p.1).collect();
        let b: Vec<&String> = pn.iter().filter(|p| &p.0 == k).map(|p| &p.1).collect();
        if a != b {
            // the standard's changes[] (update_footprints_from_library, #27) beside both signatures
            let changes: Vec<String> = match (a.first(), b.first()) { (Some(x), Some(y)) if a.len() == b.len() => pad_changes(x, y), _ => vec!["count".into()] };
            changed_pads.push(json!({"pad": k, "changes": changes, "board": a, "library": b}));
        }
    }
    let gfx_changed = graphic_sig(old) != graphic_sig(new);
    let mo: Vec<Value> = fp_text::models(old).iter().map(|m| m["path"].clone()).collect();
    let mn: Vec<Value> = fp_text::models(new).iter().map(|m| m["path"].clone()).collect();
    let mut changes: Vec<String> = Vec::new();
    if !added.is_empty() {
        changes.push(format!("pads added: {}", added.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
    }
    if !removed.is_empty() {
        changes.push(format!("pads removed: {}", removed.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")));
    }
    if !changed_pads.is_empty() {
        changes.push(format!("{} pad(s) changed shape, size, position, drill or layers", changed_pads.len()));
    }
    if gfx_changed {
        changes.push("graphics (silk, fab, courtyard) differ".into());
    }
    if mo != mn {
        changes.push("3D models differ".into());
    }
    json!({"identical": changes.is_empty(), "changes": changes, "padsAdded": added, "padsRemoved": removed, "padsChanged": changed_pads, "graphicsChanged": gfx_changed, "modelsBoard": mo, "modelsLibrary": mn})
}

/// `kicad_swap_footprint` (the standard's swap_footprint): one part (`ref`) gets another library footprint (`lib_id`,
/// its text in `lib_text`) at the same pose and side, keeping its reference, fields, symbol link, flags and lock; pads keep
/// their nets by number, and `assigns` ({"3": "GND"} or "3=GND;4=VCC") sets nets on any pad.
pub fn swap_footprint(text: &str, args: &Value, lib_text: &str, lib_id: &str) -> RResult<Change> {
    let root = parse_root(text)?;
    let board = parse_board(text)?;
    let refs = refs_arg(args)?;
    if refs.len() != 1 {
        return Err(err("invalid_args", "swap_footprint takes one ref: the part whose footprint changes"));
    }
    let fp = find_refs(&root, &refs)?[0];
    let copper = fp_text::copper_count(&root);
    let mut block = updated_block_as(text, fp, lib_text, lib_id, copper)?;
    let mut assigns = Map::new();
    match args.get("assigns") {
        Some(Value::Object(o)) => assigns = o.clone(),
        Some(Value::String(s)) => {
            for kv in s.split(';').map(str::trim).filter(|x| !x.is_empty()) {
                let (k, v) = kv.split_once('=').ok_or_else(|| err("invalid_args", format!("assigns entry '{kv}' is not pad=NET")))?;
                assigns.insert(k.trim().to_string(), json!(v.trim()));
            }
        }
        _ => {}
    }
    if !assigns.is_empty() {
        block = set_pad_nets(&block, &board, &assigns)?;
    }
    let nf = sxedit::parse(&block).map_err(|e| err("edit_failed", e))?;
    let old_nums: std::collections::BTreeSet<&str> = fp.kids_tagged("pad").filter_map(|p| p.atom(1)).filter(|n| !n.is_empty()).collect();
    let new_pads: Vec<&Node> = nf.kids_tagged("pad").collect();
    let kept: Vec<&str> = new_pads.iter().filter_map(|p| p.atom(1)).filter(|n| old_nums.contains(n)).collect();
    let no_net: Vec<&str> = new_pads.iter().filter(|p| p.kid("net").is_none() && p.atom(2) != Some("np_thru_hole")).filter_map(|p| p.atom(1)).filter(|n| !n.is_empty()).collect();
    let report = json!({"ref": refs[0], "from": fp.atom(1).unwrap_or(""), "to": lib_id, "padsBefore": fp.kids_tagged("pad").count(), "padsAfter": new_pads.len(),
        "netsKeptOnPads": kept, "padsWithoutNet": no_net, "assigned": assigns});
    replace_blocks(text, &root, &[(fp, block)], format!("Adom swap {} to {lib_id}", refs[0]), true, report)
}

/// `kicad_update_footprints_from_library`: per footprint, what the library version changes;
/// with `apply:true` the change replaces every footprint that differs. `resolve` maps a
/// library id to the `.kicad_mod` text (Err: why it could not be found).
pub fn update_from_library(text: &str, args: &Value, resolve: &mut dyn FnMut(&str) -> Result<String, String>) -> RResult<Change> {
    let root = parse_root(text)?;
    let fps: Vec<&Node> = match (args.get("refs"), args.get("ref")) {
        (None, None) => footprints(&root),
        _ => find_refs(&root, &refs_arg(args)?)?,
    };
    let copper = fp_text::copper_count(&root);
    let mut plan = Vec::new();
    let mut rows: Vec<(&Node, String)> = Vec::new();
    let mut unresolved = 0;
    for f in &fps {
        let lib_id = f.atom(1).unwrap_or("").to_string();
        let lib = match resolve(&lib_id) {
            Ok(t) => t,
            Err(why) => {
                unresolved += 1;
                plan.push(json!({"ref": fp_ref(f), "libraryId": lib_id, "footprint": lib_name(&lib_id).1, "library": lib_name(&lib_id).0, "status": "library_not_found", "why": why}));
                continue;
            }
        };
        let block = match updated_block(text, f, &lib, copper) {
            Ok(b) => b,
            Err(e) => {
                plan.push(json!({"ref": fp_ref(f), "libraryId": lib_id, "footprint": lib_name(&lib_id).1, "library": lib_name(&lib_id).0, "status": "unusable", "why": e.message}));
                continue;
            }
        };
        let nf = sxedit::parse(&block).map_err(|e| err("edit_failed", e))?;
        let diff = footprint_diff(f, &nf);
        let identical = diff["identical"] == json!(true);
        plan.push(json!({"ref": fp_ref(f), "libraryId": lib_id, "footprint": lib_name(&lib_id).1, "library": lib_name(&lib_id).0, "status": if identical { "up_to_date" } else { "differs" }, "diff": diff}));
        if !identical {
            rows.push((*f, block));
        }
    }
    let differs = rows.len();
    let report = json!({"footprints": plan, "checked": fps.len(), "differ": differs, "differs": differs, "unresolved": unresolved});
    if rows.is_empty() {
        return Ok(Change { candidate: text.to_string(), report, label: "Adom update footprints".into(), ..Default::default() });
    }
    let names: Vec<String> = rows.iter().map(|(f, _)| fp_ref(f)).collect();
    replace_blocks(text, &root, &rows, format!("Adom update {} from library", if names.len() <= 4 { names.join(", ") } else { format!("{} footprints", names.len()) }), true, report)
}

// ---------------------------------------------------------------------------
// Footprint graphics and 3D models
// ---------------------------------------------------------------------------

/// `kicad_footprint_graphics`: list one footprint's graphics, or edit one by uuid.
pub fn footprint_graphics(text: &str, args: &Value) -> RResult<(Value, Option<Change>)> {
    let root = parse_root(text)?;
    let f = find_refs(&root, &refs_arg(args)?)?[0];
    let list = json!({"ref": fp_ref(f), "graphics": fp_text::graphics(f), "frame": "footprint-local mm, unrotated (add the footprint's at and rotation for board coordinates)"});
    let Some(uuid) = args.get("uuid").and_then(Value::as_str) else {
        return Ok((list, None));
    };
    let change = args.get("change").cloned().unwrap_or_else(|| args.clone());
    let block = fp_text::edit_graphic(f.src(text), uuid, &change).map_err(|e| err("invalid_args", e))?;
    let nf = sxedit::parse(&block).map_err(|e| err("edit_failed", e))?;
    let report = json!({"ref": fp_ref(f), "uuid": uuid, "before": fp_text::graphics(f).into_iter().find(|g| g["uuid"] == uuid), "after": fp_text::graphics(&nf).into_iter().find(|g| g["uuid"] == uuid)});
    let copper = fp_text::graphics(f).iter().any(|g| g["uuid"] == uuid && g["layer"].as_str().map(|l| l.ends_with(".Cu")).unwrap_or(false));
    Ok((list, Some(replace_blocks(text, &root, &[(f, block)], format!("Adom edit {} graphic", fp_ref(f)), copper, report)?)))
}

/// `kicad_footprint_models`: list one footprint's 3D models, or apply `ops`.
pub fn footprint_models(text: &str, args: &Value) -> RResult<(Value, Option<Change>)> {
    let root = parse_root(text)?;
    let f = find_refs(&root, &refs_arg(args)?)?[0];
    let list = json!({"ref": fp_ref(f), "models": fp_text::models(f)});
    let ops: Vec<Value> = match (args.get("ops"), args.get("action")) {
        (Some(Value::Array(a)), _) => a.clone(),
        (_, Some(Value::String(a))) if a != "list" => vec![args.clone()],
        _ => return Ok((list, None)),
    };
    let block = fp_text::edit_models(f.src(text), &ops).map_err(|e| err("invalid_args", e))?;
    let nf = sxedit::parse(&block).map_err(|e| err("edit_failed", e))?;
    let report = json!({"ref": fp_ref(f), "before": fp_text::models(f), "after": fp_text::models(&nf)});
    Ok((list, Some(replace_blocks(text, &root, &[(f, block)], format!("Adom 3D models {}", fp_ref(f)), false, report)?)))
}

// ---------------------------------------------------------------------------
// Cross-probe, title block, extents
// ---------------------------------------------------------------------------

/// Every symbol instance in a schematic file: (symbol uuid, reference, lib id, value).
pub fn schematic_symbols(sch_text: &str) -> Vec<(String, String, String, String, Vec<String>)> {
    let Ok(root) = sxedit::parse(sch_text) else { return Vec::new() };
    root.kids_tagged("symbol")
        .filter_map(|s| {
            let uuid = s.uuid()?.to_string();
            let prop = |name: &str| s.kids_tagged("property").find(|p| p.atom(1) == Some(name)).and_then(|p| p.atom(2)).unwrap_or("").to_string();
            let mut instance_refs = Vec::new();
            if let Some(inst) = s.kid("instances") {
                inst.walk(&mut |nd| {
                    if nd.tag() == Some("path") {
                        if let (Some(p), Some(r)) = (nd.atom(1), nd.value_of("reference")) {
                            instance_refs.push(format!("{p}|{r}"));
                        }
                    }
                });
            }
            Some((uuid, prop("Reference"), s.value_of("lib_id").unwrap_or("").to_string(), prop("Value"), instance_refs))
        })
        .collect()
}

/// `kicad_cross_probe`: from a footprint reference, a symbol uuid or a symbol path to the
/// matching board footprint(s) and schematic symbol, by the footprint's `(path ...)` link.
/// `schematics` are (file name, text) of the project's sheets.
pub fn cross_probe(board_text: &str, schematics: &[(String, String)], args: &Value) -> RResult<Value> {
    let root = parse_root(board_text)?;
    let fps = footprints(&root);
    let want_ref = args.get("reference").or_else(|| args.get("ref")).and_then(Value::as_str);
    let want_uuid = args.get("symbolUuid").and_then(Value::as_str);
    let want_path = args.get("path").and_then(Value::as_str);
    if want_ref.is_none() && want_uuid.is_none() && want_path.is_none() {
        return Err(err("missing_arg", "Pass reference (\"U1\"), symbolUuid or path (\"/<sheet uuid>/<symbol uuid>\")"));
    }
    let last = |p: &str| p.rsplit('/').next().unwrap_or("").to_string();
    let symbols: Vec<(String, (String, String, String, String, Vec<String>))> = schematics.iter().flat_map(|(f, t)| schematic_symbols(t).into_iter().map(move |s| (f.clone(), s))).collect();
    let matches: Vec<&Node> = fps
        .iter()
        .copied()
        .filter(|f| {
            let path = f.value_of("path").unwrap_or("");
            want_ref.map(|r| fp_ref(f).eq_ignore_ascii_case(r)).unwrap_or(false)
                || want_uuid.map(|u| !path.is_empty() && last(path) == u).unwrap_or(false)
                || want_path.map(|p| path == p).unwrap_or(false)
        })
        .collect();
    let rows: Vec<Value> = matches
        .iter()
        .map(|f| {
            let path = f.value_of("path").unwrap_or("").to_string();
            let sym_uuid = last(&path);
            let sym = symbols.iter().find(|(_, s)| !sym_uuid.is_empty() && s.0 == sym_uuid);
            json!({
                "ref": fp_ref(f), "footprintUuid": f.uuid(), "footprint": f.atom(1), "side": fp_text::side(f), "at": fp_text::pose(f),
                "symbolPath": if path.is_empty() { Value::Null } else { json!(path) }, "symbolUuid": if sym_uuid.is_empty() { Value::Null } else { json!(sym_uuid) },
                "sheetName": f.value_of("sheetname"), "sheetFile": f.value_of("sheetfile"),
                "symbol": sym.map(|(file, s)| json!({"file": file, "uuid": s.0, "reference": s.1, "libId": s.2, "value": s.3})),
                "linked": !path.is_empty(),
            })
        })
        .collect();
    // a symbol named by uuid or reference that has no footprint
    let orphan = if rows.is_empty() {
        symbols.iter().find(|(_, s)| want_uuid.map(|u| s.0 == u).unwrap_or(false) || want_ref.map(|r| s.1.eq_ignore_ascii_case(r) || s.4.iter().any(|i| i.ends_with(&format!("|{r}")))).unwrap_or(false))
            .map(|(file, s)| json!({"file": file, "uuid": s.0, "reference": s.1, "libId": s.2, "value": s.3}))
    } else {
        None
    };
    Ok(json!({"matches": rows, "count": rows.len(), "symbolWithoutFootprint": orphan, "schematicsRead": schematics.iter().map(|(f, _)| f.clone()).collect::<Vec<_>>()}))
}

/// The board's title block (title, date, rev, company, comments).
pub fn title_block(text: &str) -> Value {
    let Ok(root) = sxedit::parse(text) else { return Value::Null };
    let Some(tb) = root.kid("title_block") else { return json!({}) };
    let mut out = json!({});
    for f in ["title", "date", "rev", "company"] {
        if let Some(v) = tb.value_of(f) {
            out[f] = json!(v);
        }
    }
    let comments: Vec<Value> = tb.kids_tagged("comment").filter_map(|c| Some(json!({"n": c.f64_at(1)?, "text": c.atom(2)?}))).collect();
    if !comments.is_empty() {
        out["comments"] = json!(comments);
    }
    out
}

/// The box around everything on the board (outline, courtyards, copper), in KiCad's frame.
pub fn extents(board: &Board) -> Value {
    let mut pts: Vec<(f64, f64)> = Vec::new();
    if let Some(d) = &board.dimensions {
        pts.push((d.min_x, d.min_y));
        pts.push((d.max_x, d.max_y));
    }
    for p in placement::placements(board) {
        pts.push((p.courtyard.min_x, p.courtyard.min_y));
        pts.push((p.courtyard.max_x, p.courtyard.max_y));
    }
    for s in &board.segments {
        for p in [&s.start, &s.end].into_iter().flatten() {
            pts.push((p.x, p.y));
        }
    }
    for v in &board.vias {
        if let Some(p) = &v.position {
            pts.push((p.x, p.y));
        }
    }
    match placement::Rect::from_points(pts) {
        Some(r) => json!({"x0": pcb::round6(r.min_x), "y0": pcb::round6(r.min_y), "x1": pcb::round6(r.max_x), "y1": pcb::round6(r.max_y), "widthMm": pcb::round6(r.width()), "heightMm": pcb::round6(r.height()), "frame": "kicad (y down)"}),
        None => Value::Null,
    }
}

// ---------------------------------------------------------------------------
// Closed-file writes and the live create text
// ---------------------------------------------------------------------------

/// Write `candidate` over the closed board at `path`, which must still hold `original`:
/// refused while KiCad has it open (its lock file), backed up to `<board>.adom-bak`,
/// replaced through a temp file, and read back.
pub fn write_closed(path: &Path, original: &str, candidate: &str) -> RResult<String> {
    if pcb::lock_file(path).is_some() {
        return Err(err("board_open", "KiCad has this board open (its lock file is present); a file edit would be overwritten by the editor").with("_hint", json!("Close the board in KiCad, or turn on the IPC API (kicad_ipc_api {\"enable\":true}, relaunch) so the bridge edits it live.")));
    }
    sxedit::parse(candidate).map_err(|e| err("edit_failed", format!("the edited board does not parse: {e}")))?;
    pcb::parse_pcb_text(candidate, &path.to_string_lossy()).map_err(|e| err("edit_failed", format!("the edited board does not parse: {e}")))?;
    let current = std::fs::read_to_string(path).map_err(|e| err("board_unreadable", e.to_string()))?;
    if current != original {
        return Err(err("stale_board", "The board file changed while this edit was prepared; read it again"));
    }
    let bak = PathBuf::from(format!("{}.adom-bak", path.to_string_lossy()));
    std::fs::copy(path, &bak).map_err(|e| err("board_write_refused", format!("cannot back up the board: {e}")))?;
    let dir = path.parent().unwrap_or(Path::new("."));
    let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
    let tmp = dir.join(format!("{name}.{}.{}.adom-tmp", std::process::id(), new_uuid().split('-').next().unwrap_or("x")));
    let result = (|| -> Result<(), String> {
        use std::io::Write;
        let mut f = std::fs::File::create(&tmp).map_err(|e| e.to_string())?;
        f.write_all(candidate.as_bytes()).map_err(|e| e.to_string())?;
        f.sync_all().map_err(|e| e.to_string())?;
        drop(f);
        std::fs::rename(&tmp, path).map_err(|e| e.to_string())
    })();
    let _ = std::fs::remove_file(&tmp);
    result.map_err(|e| err("board_write_refused", e))?;
    let back = std::fs::read_to_string(path).map_err(|e| err("board_unreadable", e.to_string()))?;
    if back != candidate {
        return Err(RoutingError::new("write_unverified", "The board file does not read back as written").with("mutated", Value::Null));
    }
    Ok(bak.to_string_lossy().replace('\\', "/"))
}

/// Read-back of the footprint expectations on an edited board.
pub fn verify(board: &Board, expect: &[Expect]) -> (bool, Vec<Value>) {
    let mut ok = true;
    let rows = expect
        .iter()
        .map(|x| {
            let f = board.footprints.iter().find(|f| f.reference == x.reference);
            let good = match (x.present, f) {
                (false, None) => true,
                (false, Some(_)) | (true, None) => false,
                (true, Some(f)) => {
                    let pose_ok = x.pose.map(|(px, py, pr)| f.position.as_ref().map(|p| (p.x - px).abs() < 1e-4 && (p.y - py).abs() < 1e-4 && (fp_text::norm_deg(p.rotation.unwrap_or(0.0)) - fp_text::norm_deg(pr)).abs() < 1e-3).unwrap_or(false)).unwrap_or(true);
                    let side_ok = x.side.as_ref().map(|s| f.layer.as_deref() == Some(s.as_str())).unwrap_or(true);
                    pose_ok && side_ok
                }
            };
            ok &= good;
            json!({"ref": x.reference, "present": f.is_some(), "expected": x.present, "at": f.and_then(|f| f.position.clone()), "side": f.and_then(|f| f.layer.clone()), "ok": good})
        })
        .collect();
    (ok, rows)
}

/// The text a live `ParseAndCreateItemsFromString` gets: the blocks inside a board header
/// with this board's version, layer table and (pre-KiCad 10) net table.
pub fn create_text(board_text: &str, blocks: &[String]) -> String {
    let root = sxedit::parse(board_text).ok();
    let mut s = String::from("(kicad_pcb");
    if let Some(r) = &root {
        for t in ["version", "generator", "generator_version", "layers"] {
            if let Some(k) = r.kid(t) {
                s.push_str("\n\t");
                s.push_str(k.src(board_text));
            }
        }
        for k in r.kids_tagged("net") {
            s.push_str("\n\t");
            s.push_str(k.src(board_text));
        }
    }
    for b in blocks {
        s.push_str("\n\t");
        s.push_str(b.trim());
    }
    s.push_str("\n)\n");
    s
}

// -- edit_pours ---------------------------------------------------------------------------------

/// A zone's copper layers as the file names them (`layer` or `layers`).
fn zone_layers(z: &Node) -> Vec<String> {
    let l = z.kid("layers").or_else(|| z.kid("layer"));
    l.map(|l| l.kids.iter().skip(1).filter_map(|k| k.atom.clone()).collect()).unwrap_or_default()
}

/// A zone's net name: KiCad 10's `(net "GND")`, or an older file's `(net 3) (net_name "GND")`.
fn zone_net(z: &Node) -> String {
    z.value_of("net_name").or_else(|| z.kid("net").filter(|n| n.kids.get(1).map_or(false, |k| k.quoted)).and_then(|n| n.atom(1))).unwrap_or("").to_string()
}

/// How pads join the pour: `connect_pads` with no mode is thermal reliefs.
fn zone_connection(z: &Node) -> &'static str {
    match z.kid("connect_pads").and_then(|c| c.atom(1)) {
        Some("yes") => "solid",
        Some("no") => "none",
        Some("thru_hole_only") => "reliefsOnThroughHole",
        _ => "thermal",
    }
}

/// One pour's settings as the file holds them, in the standard's names (edit_pours' before and after).
pub fn pour_settings(z: &Node) -> Value {
    let fill = z.kid("fill");
    let fnum = |tag: &str| fill.and_then(|f| f.kid(tag)).and_then(|k| k.f64_at(1));
    let connection = zone_connection(z);
    let islands = fnum("island_removal_mode").unwrap_or(0.0) as i64;
    let mut v = json!({
        "id": z.uuid(), "net": zone_net(z), "layers": zone_layers(z), "name": z.value_of("name"),
        "clearanceMm": z.kid("connect_pads").and_then(|c| c.kid("clearance")).and_then(|k| k.f64_at(1)),
        "minWidthMm": z.kid("min_thickness").and_then(|k| k.f64_at(1)),
        "thermals": {"on": matches!(connection, "thermal" | "reliefsOnThroughHole"), "spokeMm": fnum("thermal_bridge_width"), "gapMm": fnum("thermal_gap")},
        "connection": connection,
        "priority": z.kid("priority").and_then(|k| k.f64_at(1)).unwrap_or(0.0) as i64,
        "keepIslands": islands == 1,
    });
    if islands == 2 {
        v["islandAreaMinMm2"] = json!(fnum("island_area_min"));
    }
    v
}

/// Every net name the board declares or uses.
pub fn board_net_names(root: &Node) -> Vec<String> {
    let mut names = Vec::new();
    root.walk(&mut |k| {
        if k.tag() == Some("net") || k.tag() == Some("net_name") {
            if let Some(s) = k.kids.iter().skip(1).find(|a| a.quoted).and_then(|a| a.atom.clone()) {
                names.push(s);
            }
        }
    });
    names.sort();
    names.dedup();
    names
}

fn layer_matches(zone: &[String], want: &str) -> bool {
    zone.iter().any(|l| l == want || l == "*.Cu" && want.ends_with(".Cu") || l == "F&B.Cu" && (want == "F.Cu" || want == "B.Cu"))
}

/// The board's own zones (not footprint zones, not keepouts) one pour selector names: `id`, `pourIndex` (as pour_state
/// numbers them), `name`, or `net` with an optional `layer`.
fn select_zones<'a>(root: &'a Node, text: &str, sel: &Value) -> RResult<Vec<&'a Node>> {
    let zones: Vec<&Node> = root.kids_tagged("zone").filter(|z| z.kid("keepout").is_none()).collect();
    let s = |k: &str| sel.get(k).and_then(Value::as_str);
    let id = match (s("id"), sel.get("pourIndex").and_then(Value::as_u64)) {
        (Some(id), _) => Some(id.to_string()),
        (None, Some(k)) => {
            let p = crate::board_model::pours(text, &crate::board_model::Opts { y_up: true }, false).map_err(|e| err("board_unparsable", e))?;
            let hit = p["zones"].as_array().into_iter().flatten().find(|z| z["pourIndex"].as_u64() == Some(k)).and_then(|z| z["id"].as_str().map(str::to_string));
            Some(hit.ok_or_else(|| err("pour_not_found", format!("no pour has pourIndex {k}")))?)
        }
        _ => None,
    };
    let hits: Vec<&Node> = zones.iter().copied().filter(|z| match (&id, s("name"), s("net")) {
        (Some(id), _, _) => z.uuid() == Some(id.as_str()),
        (None, Some(name), _) => z.value_of("name") == Some(name),
        (None, None, Some(net)) => zone_net(z) == net && s("layer").map_or(true, |l| layer_matches(&zone_layers(z), l)),
        _ => false,
    }).collect();
    if id.is_none() && s("name").is_none() && s("net").is_none() {
        return Err(err("invalid_args", "each pour needs id (from kicad_pour_state), pourIndex, name, or net with an optional layer"));
    }
    if hits.is_empty() {
        let have: Vec<Value> = zones.iter().map(|z| json!({"id": z.uuid(), "net": zone_net(z), "layers": zone_layers(z), "name": z.value_of("name")})).collect();
        return Err(err("pour_not_found", format!("no pour matches {sel}")).with("pours", json!(have)));
    }
    Ok(hits)
}

fn mm_arg(p: &Value, key: &str) -> RResult<Option<f64>> {
    match p.get(key) {
        None | Some(Value::Null) => Ok(None),
        Some(v) => match v.as_f64() {
            Some(x) if x >= 0.0 && x < 100.0 => Ok(Some(x)),
            _ => Err(err("invalid_args", format!("{key} must be a distance in mm (0 to 100), not {v}"))),
        },
    }
}

/// One zone's text with the requested settings changed; untouched settings keep KiCad's bytes.
fn edit_zone(block: &str, p: &Value, root: &Node, old_format: bool) -> RResult<String> {
    let z = sxedit::parse(block).map_err(|e| err("edit_failed", e))?;
    let mut e = Edits::new();
    // the last child's end: new children go before the closing paren
    let close = |k: &Node| k.end - 1;
    let set_kid = |e: &mut Edits, parent: &Node, tag: &str, val: String| match parent.kid(tag) {
        Some(k) => e.replace_node(k, format!("({tag} {val})")),
        None => e.insert(close(parent), format!(" ({tag} {val})")),
    };
    let clearance = mm_arg(p, "clearanceMm")?;
    let thermals = p.get("thermals").filter(|t| !t.is_null());
    let mut connection = p.get("connection").and_then(Value::as_str).map(str::to_string);
    if let Some(on) = thermals.and_then(|t| t.get("on")).and_then(Value::as_bool) {
        let now = zone_connection(&z);
        connection.get_or_insert_with(|| if on { if now == "reliefsOnThroughHole" { now.into() } else { "thermal".into() } } else { "solid".into() });
    }
    if clearance.is_some() || connection.is_some() {
        let cp = z.kid("connect_pads");
        let mode = match connection.as_deref().unwrap_or(zone_connection(&z)) {
            "thermal" => "",
            "solid" => " yes",
            "none" => " no",
            "reliefsOnThroughHole" => " thru_hole_only",
            o => return Err(err("invalid_args", format!("connection must be thermal, solid, none or reliefsOnThroughHole, not '{o}'"))),
        };
        let c = clearance.or_else(|| cp.and_then(|c| c.kid("clearance")).and_then(|k| k.f64_at(1))).unwrap_or(0.5);
        let text = format!("(connect_pads{mode} (clearance {}))", n(c));
        match cp {
            Some(cp) => e.replace_node(cp, text),
            None => e.insert(z.kid("min_thickness").or_else(|| z.kid("polygon")).map_or(close(&z), |k| k.start), format!("{text} ")),
        }
    }
    if let Some(w) = mm_arg(p, "minWidthMm")? {
        set_kid(&mut e, &z, "min_thickness", n(w));
    }
    if let Some(pr) = p.get("priority").filter(|v| !v.is_null()) {
        let pr = pr.as_u64().filter(|x| *x < 1000).ok_or_else(|| err("invalid_args", format!("priority must be a whole number 0 to 999, not {pr}")))?;
        match z.kid("priority") {
            Some(k) => e.replace_node(k, format!("(priority {pr})")),
            None if pr == 0 => {}
            None => e.insert(z.kid("connect_pads").or_else(|| z.kid("min_thickness")).map_or(close(&z), |k| k.start), format!("(priority {pr}) ")),
        }
    }
    let spoke = thermals.map(|t| mm_arg(t, "spokeMm")).transpose()?.flatten();
    let gap = thermals.map(|t| mm_arg(t, "gapMm")).transpose()?.flatten();
    let islands = p.get("keepIslands").filter(|v| !v.is_null()).map(|v| v.as_bool().ok_or_else(|| err("invalid_args", "keepIslands is true or false"))).transpose()?;
    if spoke.is_some() || gap.is_some() || islands.is_some() {
        let rows: Vec<(&str, String)> = [("thermal_gap", gap.map(n)), ("thermal_bridge_width", spoke.map(n)), ("island_removal_mode", islands.map(|k| if k { "1".into() } else { "0".into() }))]
            .into_iter().filter_map(|(t, v)| v.map(|v| (t, v))).collect();
        match z.kid("fill") {
            Some(f) => rows.into_iter().for_each(|(t, v)| set_kid(&mut e, f, t, v)),
            None => {
                let inner: String = rows.iter().map(|(t, v)| format!(" ({t} {v})")).collect();
                e.insert(z.kid("polygon").map_or(close(&z), |k| k.start), format!("(fill{inner}) "));
            }
        }
    }
    if let Some(net) = p.get("net").and_then(Value::as_str) {
        let known = board_net_names(root);
        if !net.is_empty() && !known.iter().any(|k| k == net) {
            return Err(err("unknown_net", format!("the board has no net '{net}'")).with("nets", json!(known)));
        }
        if old_format {
            let num = root.kids_tagged("net").find(|k| k.atom(2) == Some(net)).and_then(|k| k.atom(1)).unwrap_or("0").to_string();
            if let Some(k) = z.kid("net") { e.replace_node(k, format!("(net {num})")); }
            match z.kid("net_name") {
                Some(k) => e.replace_node(k, format!("(net_name {})", q(net))),
                None => e.insert(close(&z), format!(" (net_name {})", q(net))),
            }
        } else {
            match z.kid("net") {
                Some(k) => e.replace_node(k, format!("(net {})", q(net))),
                None => e.insert(z.kids.get(1).map_or(close(&z), |k| k.start), format!("(net {}) ", q(net))),
            }
        }
    }
    let out = e.apply(block).map_err(|x| err("edit_failed", x))?;
    sxedit::parse(&out).map_err(|x| err("edit_failed", format!("the edited zone does not parse: {x}")))?;
    Ok(out)
}

// -- footprint_primitives ---------------------------------------------------------------------------

const MIL: f64 = 0.0254;

fn mil(v: f64) -> f64 {
    (v / MIL * 100.0).round() / 100.0
}

/// `kicad_footprint_primitives`: one placed footprint's geometry on the board (the standard's footprint_primitives):
/// its graphic lines (track) and arcs, pads (with solder and paste mask expansion in um) and texts, in mils in KiCad's
/// board frame, each with its layer; the other kinds (polygons, circles, rectangles, zones, models) are counted.
pub fn footprint_primitives(text: &str, reference: &str) -> RResult<Value> {
    let root = parse_root(text)?;
    let fp = footprints(&root).into_iter().find(|f| fp_ref(f).eq_ignore_ascii_case(reference))
        .ok_or_else(|| err("part_not_found", format!("the board has no part {reference}")))?;
    let (x, y, rot) = fp_text::pose(fp);
    let at = |n: &Node, tag: &str| -> Option<(f64, f64)> {
        let p = n.kid(tag)?;
        Some(placement::to_board((x, y), rot, (p.f64_at(1)?, p.f64_at(2)?)))
    };
    let pt = |p: (f64, f64)| json!([mil(p.0), mil(p.1)]);
    let stroke = |n: &Node| n.kid("stroke").and_then(|s| s.kid("width")).or_else(|| n.kid("width")).and_then(|w| w.f64_at(1)).map(mil);
    let fp_mask = fp.kid("solder_mask_margin").and_then(|m| m.f64_at(1));
    let fp_paste = fp.kid("solder_paste_margin").and_then(|m| m.f64_at(1));
    let mut prims = Vec::new();
    let mut other: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
    for k in fp.kids.iter().filter(|k| k.is_list()) {
        let layer = k.value_of("layer").map(str::to_string);
        match k.tag() {
            Some("fp_line") => prims.push(json!({"kind": "track", "layerId": layer, "start": at(k, "start").map(pt), "end": at(k, "end").map(pt), "widthMil": stroke(k)})),
            Some("fp_arc") => prims.push(json!({"kind": "arc", "layerId": layer, "start": at(k, "start").map(pt), "mid": at(k, "mid").map(pt), "end": at(k, "end").map(pt), "widthMil": stroke(k)})),
            Some("pad") => {
                let pa = k.kid("at");
                let local = (pa.and_then(|a| a.f64_at(1)).unwrap_or(0.0), pa.and_then(|a| a.f64_at(2)).unwrap_or(0.0));
                let size = k.kid("size");
                let layers: Vec<String> = k.kid("layers").map(|l| l.kids.iter().skip(1).filter_map(|a| a.atom.clone()).collect()).unwrap_or_default();
                let um = |own: Option<f64>, fallback: Option<f64>| own.or(fallback).map(|m| (m * 1000.0).round());
                prims.push(json!({
                    "kind": "pad", "name": k.atom(1), "type": k.atom(2), "shape": k.atom(3),
                    "at": pt(placement::to_board((x, y), rot, local)), "rotation": pa.and_then(|a| a.f64_at(3)).unwrap_or(0.0),
                    "sizeMil": [size.and_then(|s| s.f64_at(1)).map(mil), size.and_then(|s| s.f64_at(2)).map(mil)],
                    "holeMil": k.kid("drill").and_then(|d| d.kids.iter().skip(1).find_map(|a| a.atom.as_deref().and_then(|v| v.parse::<f64>().ok()))).map(mil),
                    "layerId": layers.first(), "layers": layers, "net": k.kid("net").and_then(|n| n.kids.iter().skip(1).filter(|a| a.quoted).last()).and_then(|a| a.atom.clone()),
                    "solderMaskExpansionUm": um(k.kid("solder_mask_margin").and_then(|m| m.f64_at(1)), fp_mask),
                    "pasteMaskExpansionUm": um(k.kid("solder_paste_margin").and_then(|m| m.f64_at(1)), fp_paste),
                }));
            }
            Some("fp_text") | Some("property") if k.kid("layer").is_some() && k.kid("at").is_some() => {
                let hidden = k.has_word("hide") || k.value_of("hide") == Some("yes") || k.kid("effects").map_or(false, |e| e.has_word("hide") || e.value_of("hide") == Some("yes"));
                let height = k.kid("effects").and_then(|e| e.kid("font")).and_then(|f| f.kid("size")).and_then(|s| s.f64_at(1)).map(mil);
                // (property "Reference" "R1" ...) and (fp_text reference "R1" ...) both name the field, then the text
                prims.push(json!({"kind": "text", "layerId": layer, "text": k.atom(2), "field": k.atom(1), "at": at(k, "at").map(pt), "heightMil": height, "hidden": hidden}));
            }
            Some(t) if t.starts_with("fp_") || matches!(t, "zone" | "model" | "group") => *other.entry(t.trim_start_matches("fp_").to_string()).or_default() += 1,
            _ => {}
        }
    }
    Ok(json!({"designator": fp_ref(fp), "pattern": fp.atom(1), "count": prims.len(), "otherPrimitives": other, "primitives": prims,
        "at": [mil(x), mil(y)], "rotation": rot, "side": fp_text::side(fp), "units": "mil (KiCad's board frame, y down)"}))
}

// -- remove_items -------------------------------------------------------------------------------

/// A point as the standard gives it, `{x, y}` or `[x, y]`, in KiCad's frame (y down): the standard frame's y is negated.
fn std_point(v: &Value, y_up: bool) -> Option<(f64, f64)> {
    let (x, y) = match v {
        Value::Array(a) if a.len() >= 2 => (a[0].as_f64()?, a[1].as_f64()?),
        Value::Object(_) => (v.get("x")?.as_f64()?, v.get("y")?.as_f64()?),
        _ => return None,
    };
    Some((x, if y_up { -y } else { y }))
}

/// `kicad_remove_items`: delete free tracks (and arcs) and vias given by their geometry, as kicad_routing_state
/// reported them (the standard's y-up frame unless `frame: "kicad"`). One result per item: ok, or err:not found.
pub fn remove_items(text: &str, args: &Value) -> RResult<Change> {
    let y_up = args.get("frame").and_then(Value::as_str) != Some("kicad");
    let root = parse_root(text)?;
    let near = |a: (f64, f64), b: (f64, f64)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3;
    let xy = |n: &Node, tag: &str| n.kid(tag).and_then(|k| Some((k.f64_at(1)?, k.f64_at(2)?)));
    let mut e = Edits::new();
    let (mut remove, mut results, mut taken) = (Vec::new(), Vec::new(), Vec::<usize>::new());
    let tracks = args.get("tracks").and_then(Value::as_array).cloned().unwrap_or_default();
    let vias = args.get("vias").and_then(Value::as_array).cloned().unwrap_or_default();
    if tracks.is_empty() && vias.is_empty() {
        return Err(err("invalid_args", "tracks or vias is required: [{start{x,y}, end{x,y}, layer}] / [{x,y}]"));
    }
    for t in &tracks {
        let (Some(a), Some(b)) = (t.get("start").and_then(|p| std_point(p, y_up)), t.get("end").and_then(|p| std_point(p, y_up))) else {
            results.push(json!("err:bad item (start and end are required)"));
            continue;
        };
        let layer = t.get("layer").and_then(Value::as_str);
        let hit = root.kids.iter().enumerate().find(|(i, k)| {
            !taken.contains(i) && matches!(k.tag(), Some("segment") | Some("arc")) && layer.map_or(true, |l| k.value_of("layer") == Some(l))
                && match (xy(k, "start"), xy(k, "end")) { (Some(s), Some(en)) => (near(s, a) && near(en, b)) || (near(s, b) && near(en, a)), _ => false }
        });
        push_hit(hit, &mut e, text, &mut remove, &mut results, &mut taken);
    }
    for v in &vias {
        let Some(p) = std_point(v.get("position").unwrap_or(v), y_up) else {
            results.push(json!("err:bad item (x and y are required)"));
            continue;
        };
        let hit = root.kids.iter().enumerate().find(|(i, k)| !taken.contains(i) && k.tag() == Some("via") && xy(k, "at").map_or(false, |q| near(q, p)));
        push_hit(hit, &mut e, text, &mut remove, &mut results, &mut taken);
    }
    let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;
    let removed = remove.len();
    Ok(Change {
        candidate, remove, copper: true,
        report: json!({"results": results, "removed": removed}),
        label: format!("Adom remove {removed} item(s)"),
        ..Default::default()
    })
}

fn push_hit(hit: Option<(usize, &Node)>, e: &mut Edits, text: &str, remove: &mut Vec<String>, results: &mut Vec<Value>, taken: &mut Vec<usize>) {
    match hit.and_then(|(i, k)| k.uuid().map(|u| (i, k, u.to_string()))) {
        Some((i, k, u)) => {
            e.remove_node(text, k);
            remove.push(u);
            taken.push(i);
            results.push(json!("ok"));
        }
        None => results.push(json!("err:not found")),
    }
}

// -- set_track_width ----------------------------------------------------------------------------

/// A track or arc's net name: KiCad 10's `(net "GND")`, or an older file's `(net 3)` through the board's net table.
fn item_net(item: &Node, table: &std::collections::HashMap<String, String>) -> String {
    match item.kid("net") {
        Some(n) if n.kids.get(1).map_or(false, |k| k.quoted) => n.atom(1).unwrap_or("").to_string(),
        Some(n) => n.atom(1).and_then(|c| table.get(c)).cloned().unwrap_or_default(),
        None => String::new(),
    }
}

/// The copper layer a standard layer word names: top, bottom, `mid N` (inner layer N), or KiCad's own name.
pub fn track_layer(word: &str) -> String {
    let w = word.trim();
    match w.to_ascii_lowercase().as_str() {
        "top" => "F.Cu".into(),
        "bottom" => "B.Cu".into(),
        l if l.starts_with("mid") => format!("In{}.Cu", l.trim_start_matches("mid").trim()),
        _ => w.to_string(),
    }
}

/// The board's tracks and arcs on `nets` (and `layer`, a KiCad layer name, when given): (uuid, net, width now).
pub fn tracks_on(text: &str, nets: &[String], layer: Option<&str>) -> RResult<Vec<(String, String, f64)>> {
    let root = parse_root(text)?;
    let table: std::collections::HashMap<String, String> = root.kids_tagged("net").filter_map(|n| Some((n.atom(1)?.to_string(), n.atom(2)?.to_string()))).collect();
    let mut out = Vec::new();
    for item in root.kids.iter().filter(|k| matches!(k.tag(), Some("segment") | Some("arc"))) {
        let net = item_net(item, &table);
        if !nets.iter().any(|n| n == &net) {
            continue;
        }
        if let Some(l) = layer {
            if item.value_of("layer") != Some(l) {
                continue;
            }
        }
        let Some(id) = item.uuid() else { continue };
        out.push((id.to_string(), net, item.kid("width").and_then(|w| w.f64_at(1)).unwrap_or(0.0)));
    }
    Ok(out)
}

/// `kicad_set_track_width` on a closed file: every track and arc on `nets` (and `layer`) gets `width` mm, each
/// keeping its uuid, ends and net. The report counts the changes per net.
pub fn set_track_width(text: &str, nets: &[String], layer: Option<&str>, width: f64) -> RResult<Change> {
    if !(width > 0.0 && width < 50.0) {
        return Err(err("invalid_args", format!("widthMm must be between 0 and 50 mm, not {width}")));
    }
    let hits = tracks_on(text, nets, layer)?;
    let root = parse_root(text)?;
    let mut e = Edits::new();
    let mut by_net: std::collections::BTreeMap<String, u64> = std::collections::BTreeMap::new();
    for item in root.kids.iter().filter(|k| matches!(k.tag(), Some("segment") | Some("arc"))) {
        let Some(id) = item.uuid() else { continue };
        let Some((_, net, now)) = hits.iter().find(|(i, _, _)| i == id) else { continue };
        *by_net.entry(net.clone()).or_default() += 1;
        if (now - width).abs() < 1e-9 {
            continue;
        }
        match item.kid("width") {
            Some(w) => e.replace_node(w, format!("(width {})", n(width))),
            None => e.insert(item.end - 1, format!(" (width {})", n(width))),
        }
    }
    let changed = hits.iter().filter(|(_, _, w)| (w - width).abs() >= 1e-9).count();
    let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;
    Ok(Change {
        candidate, copper: true,
        report: json!({"changed": changed, "matched": hits.len(), "byNet": by_net, "widthMm": width}),
        label: format!("Adom track width {} mm", n(width)),
        ..Default::default()
    })
}

/// Every board pour's settings (pour_settings), for reading an edit back.
pub fn all_pour_settings(text: &str) -> RResult<Vec<Value>> {
    let root = parse_root(text)?;
    Ok(root.kids_tagged("zone").filter(|z| z.kid("keepout").is_none()).map(pour_settings).collect())
}

/// The pours[] entries of a kicad_edit_pours call (a single pour may be given at the top level).
pub fn pour_rows(args: &Value) -> Vec<Value> {
    match args.get("pours") {
        Some(Value::Array(a)) => a.clone(),
        _ => vec![args.clone()],
    }
}

const POUR_FIELDS: &[&str] = &["clearanceMm", "minWidthMm", "thermals", "connection", "priority", "net", "keepIslands"];

/// `kicad_edit_pours`: change existing zones in place (clearance, minimum width, thermal reliefs, pad connection,
/// priority, net, islands). Each zone keeps its outline and uuid in the file; a live commit recreates it in one Undo step.
pub fn edit_pours(text: &str, args: &Value) -> RResult<Change> {
    let root = parse_root(text)?;
    let rows: Vec<Value> = match args.get("pours") {
        Some(Value::Array(a)) if !a.is_empty() => a.clone(),
        None if args.get("id").is_some() || args.get("net").is_some() || args.get("name").is_some() => vec![args.clone()],
        _ => return Err(err("invalid_args", "pours is required: [{id or net+layer, clearanceMm?, minWidthMm?, thermals?: {on, spokeMm, gapMm}, priority?, net?, keepIslands?}]")),
    };
    let old_format = root.kids_tagged("net").any(|k| k.atom(2).is_some());
    let mut e = Edits::new();
    let (mut remove, mut create, mut edited) = (Vec::new(), Vec::new(), Vec::new());
    let mut done: Vec<String> = Vec::new();
    for (entry, p) in rows.iter().enumerate() {
        if !POUR_FIELDS.iter().any(|f| p.get(*f).map_or(false, |v| !v.is_null())) {
            return Err(err("invalid_args", format!("pour {p} changes nothing: give one of {}", POUR_FIELDS.join(", "))));
        }
        for z in select_zones(&root, text, p)? {
            let id = z.uuid().ok_or_else(|| err("zone_without_uuid", "a zone has no uuid; open and save the board in KiCad once"))?.to_string();
            if done.contains(&id) {
                return Err(err("invalid_args", format!("pour {id} is named by two entries; give each pour once")));
            }
            let block = edit_zone(z.src(text), p, &root, old_format)?;
            let after = sxedit::parse(&block).map_err(|x| err("edit_failed", x))?;
            edited.push(json!({"id": id, "entry": entry, "before": pour_settings(z), "after": pour_settings(&after)}));
            if block != z.src(text) {
                e.replace_node(z, block.clone());
                remove.push(id.clone());
                create.push(fp_text::fresh_uuids(&block).map_err(|x| err("edit_failed", x))?);
            }
            done.push(id);
        }
    }
    let candidate = e.apply(text).map_err(|x| err("edit_failed", x))?;
    parse_board(&candidate)?;
    Ok(Change {
        candidate, remove, create, copper: true,
        report: json!({"edited": edited, "count": edited.len()}),
        label: format!("Adom edit {} pour(s)", edited.len()),
        ..Default::default()
    })
}

/// Top-level item UUIDs of a board text (footprints, graphics, copper, zones).
pub fn top_level_ids(text: &str) -> Vec<String> {
    sxedit::parse(text).map(|r| r.kids.iter().filter_map(|k| k.uuid().map(str::to_string)).collect()).unwrap_or_default()
}

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

    const ZONES10: &str = "(kicad_pcb (version 20260206) (generator \"pcbnew\")\n\t(footprint \"R\" (layer \"F.Cu\") (uuid \"f1\") (property \"Reference\" \"R1\") (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"GND\") (uuid \"p1\")) (pad \"2\" smd rect (at 1 0) (size 1 1) (layers \"F.Cu\") (net \"VCC\") (uuid \"p2\")))\n\t(zone\n\t\t(net \"GND\")\n\t\t(layer \"B.Cu\")\n\t\t(uuid \"z1\")\n\t\t(name \"GND plane\")\n\t\t(hatch edge 0.5)\n\t\t(connect_pads\n\t\t\t(clearance 0.2)\n\t\t)\n\t\t(min_thickness 0.25)\n\t\t(filled_areas_thickness no)\n\t\t(fill yes\n\t\t\t(thermal_gap 0.5)\n\t\t\t(thermal_bridge_width 0.5)\n\t\t)\n\t\t(polygon (pts (xy 0 0) (xy 10 0) (xy 10 10) (xy 0 10)))\n\t)\n\t(zone (net \"VCC\") (layers \"F.Cu\" \"B.Cu\") (uuid \"z2\") (hatch edge 0.5) (priority 1) (connect_pads yes (clearance 0.3)) (min_thickness 0.2) (fill yes (thermal_gap 0.4) (thermal_bridge_width 0.4) (island_removal_mode 1)) (polygon (pts (xy 2 2) (xy 4 2) (xy 4 4))))\n)\n";

    #[test]
    fn edit_pours_changes_only_what_it_is_asked_and_keeps_the_rest_of_the_zone() {
        let c = edit_pours(ZONES10, &json!({"pours": [{"net": "GND", "layer": "B.Cu", "clearanceMm": 0.3, "thermals": {"spokeMm": 0.6}, "priority": 2, "keepIslands": true}]})).unwrap();
        let e = &c.report["edited"][0];
        assert_eq!((e["id"].as_str(), e["before"]["clearanceMm"].as_f64(), e["after"]["clearanceMm"].as_f64()), (Some("z1"), Some(0.2), Some(0.3)));
        assert_eq!((e["after"]["thermals"]["spokeMm"].as_f64(), e["after"]["thermals"]["gapMm"].as_f64(), e["after"]["priority"].as_i64(), e["after"]["keepIslands"].as_bool()), (Some(0.6), Some(0.5), Some(2), Some(true)));
        assert!(c.candidate.contains("(name \"GND plane\")\n\t\t(hatch edge 0.5)\n\t\t(priority 2) (connect_pads (clearance 0.3))"), "{}", c.candidate);
        assert!(c.candidate.contains("(min_thickness 0.25)\n\t\t(filled_areas_thickness no)"));
        // the other zone is byte-for-byte untouched; the live commit recreates only z1
        assert!(c.candidate.contains("(zone (net \"VCC\") (layers \"F.Cu\" \"B.Cu\") (uuid \"z2\")"));
        assert_eq!((c.remove.clone(), c.create.len()), (vec!["z1".to_string()], 1));
    }

    #[test]
    fn edit_pours_switches_thermals_net_and_refuses_unknown_nets_and_pours() {
        let c = edit_pours(ZONES10, &json!({"pours": [{"id": "z2", "thermals": {"on": true}, "net": "GND", "minWidthMm": 0.3}]})).unwrap();
        let a = &c.report["edited"][0]["after"];
        assert_eq!((a["connection"].as_str(), a["thermals"]["on"].as_bool(), a["net"].as_str(), a["minWidthMm"].as_f64()), (Some("thermal"), Some(true), Some("GND"), Some(0.3)));
        assert_eq!(c.report["edited"][0]["before"]["connection"], "solid");
        let off = edit_pours(ZONES10, &json!({"pours": [{"name": "GND plane", "thermals": {"on": false}}]})).unwrap();
        assert_eq!(off.report["edited"][0]["after"]["connection"], "solid");
        assert_eq!(edit_pours(ZONES10, &json!({"pours": [{"id": "z1", "net": "NOPE"}]})).unwrap_err().code, "unknown_net");
        assert_eq!(edit_pours(ZONES10, &json!({"pours": [{"net": "GND", "layer": "F.Cu", "priority": 1}]})).unwrap_err().code, "pour_not_found");
        assert_eq!(edit_pours(ZONES10, &json!({"pours": [{"id": "z1"}]})).unwrap_err().code, "invalid_args");
        // a multi-layer zone is found by any of its layers
        assert_eq!(edit_pours(ZONES10, &json!({"pours": [{"net": "VCC", "layer": "F.Cu", "priority": 3}]})).unwrap().report["edited"][0]["id"], "z2");
    }

    #[test]
    fn footprint_primitives_puts_a_parts_geometry_on_the_board_in_mils() {
        let t = "(kicad_pcb (version 20260206)\n\t(footprint \"R_0603\" (layer \"F.Cu\") (uuid \"f1\") (at 25.4 50.8 90)\n\t\t(property \"Reference\" \"R1\" (at 0 -1.27 0) (layer \"F.SilkS\") (effects (font (size 1 1))))\n\t\t(fp_line (start -0.254 0) (end 0.254 0) (stroke (width 0.12)) (layer \"F.SilkS\"))\n\t\t(fp_circle (center 0 0) (end 0.1 0) (layer \"F.Fab\"))\n\t\t(pad \"1\" smd roundrect (at -0.762 0 90) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (net \"VIN\") (solder_mask_margin 0.05))\n\t\t(pad \"2\" thru_hole circle (at 0.762 0 90) (size 1.7 1.7) (drill 1.0) (layers \"*.Cu\" \"*.Mask\"))\n\t)\n)\n";
        let v = footprint_primitives(t, "r1").unwrap();
        assert_eq!((v["designator"].as_str(), v["pattern"].as_str(), v["count"].as_u64()), (Some("R1"), Some("R_0603"), Some(4)), "{v}");
        assert_eq!(v["otherPrimitives"]["circle"], 1);
        let pad1 = v["primitives"].as_array().unwrap().iter().find(|p| p["name"] == "1").unwrap().clone();
        // rotated 90 degrees about (25.4, 50.8): local x -0.762 lands 0.762 mm below the anchor in KiCad's y-down frame
        assert_eq!((pad1["at"][0].as_f64(), pad1["at"][1].as_f64()), (Some(1000.0), Some(2030.0)), "{pad1}");
        assert_eq!((pad1["solderMaskExpansionUm"].as_f64(), pad1["net"].as_str()), (Some(50.0), Some("VIN")));
        let pad2 = v["primitives"].as_array().unwrap().iter().find(|p| p["name"] == "2").unwrap().clone();
        assert_eq!(pad2["holeMil"].as_f64(), Some(39.37));
        assert_eq!(footprint_primitives(t, "U9").unwrap_err().code, "part_not_found");
    }

    #[test]
    fn remove_items_deletes_tracks_and_vias_by_their_geometry() {
        let t = "(kicad_pcb (version 20260206)\n\t(segment (start 10 20) (end 15 20) (width 0.2) (layer \"F.Cu\") (net \"A\") (uuid \"s1\"))\n\t(segment (start 15 20) (end 15 25) (width 0.2) (layer \"B.Cu\") (net \"A\") (uuid \"s2\"))\n\t(via (at 15 20) (size 0.6) (drill 0.3) (layers \"F.Cu\" \"B.Cu\") (net \"A\") (uuid \"v1\"))\n)\n";
        // the standard's frame is y up: (10, -20) is KiCad's (10, 20); a track may be given end first
        let c = remove_items(t, &json!({"tracks": [{"start": {"x": 15, "y": -20}, "end": {"x": 10, "y": -20}, "layer": "F.Cu"}, {"start": [0, 0], "end": [1, 1]}], "vias": [{"position": {"x": 15, "y": -20}}]})).unwrap();
        assert_eq!(c.report["results"], json!(["ok", "err:not found", "ok"]), "{}", c.report);
        assert_eq!(c.remove, vec!["s1".to_string(), "v1".to_string()]);
        assert!(!c.candidate.contains("\"s1\"") && !c.candidate.contains("\"v1\"") && c.candidate.contains("\"s2\""));
        let k = remove_items(t, &json!({"frame": "kicad", "tracks": [{"start": [15, 20], "end": [15, 25]}]})).unwrap();
        assert_eq!(k.remove, vec!["s2".to_string()]);
        assert_eq!(remove_items(t, &json!({})).unwrap_err().code, "invalid_args");
    }

    #[test]
    fn set_track_width_changes_the_named_nets_tracks_on_the_layer_asked() {
        let t = "(kicad_pcb (version 20260206)\n\t(segment (start 0 0) (end 1 0) (width 0.2) (layer \"F.Cu\") (net \"VBUS\") (uuid \"s1\"))\n\t(segment (start 1 0) (end 2 0) (width 0.2) (layer \"B.Cu\") (net \"VBUS\") (uuid \"s2\"))\n\t(arc (start 2 0) (mid 2.5 0.5) (end 3 0) (width 0.25) (layer \"F.Cu\") (net \"VBUS\") (uuid \"a1\"))\n\t(segment (start 0 5) (end 1 5) (width 0.2) (layer \"F.Cu\") (net \"SIG\") (uuid \"s3\"))\n)\n";
        let c = set_track_width(t, &["VBUS".into()], Some(&track_layer("top")), 0.5).unwrap();
        assert_eq!((c.report["changed"].as_u64(), c.report["byNet"]["VBUS"].as_u64()), (Some(2), Some(2)), "{}", c.report);
        assert!(c.candidate.contains("(width 0.5) (layer \"F.Cu\") (net \"VBUS\") (uuid \"s1\")") && c.candidate.contains("(width 0.5) (layer \"F.Cu\") (net \"VBUS\") (uuid \"a1\")"));
        assert!(c.candidate.contains("(width 0.2) (layer \"B.Cu\") (net \"VBUS\") (uuid \"s2\")") && c.candidate.contains("(net \"SIG\") (uuid \"s3\")"), "other layers and nets untouched");
        let all = set_track_width(t, &["VBUS".into()], None, 0.5).unwrap();
        assert_eq!(all.report["changed"].as_u64(), Some(3));
        assert_eq!(track_layer("mid 2"), "In2.Cu");
        assert_eq!(set_track_width(t, &["VBUS".into()], None, 0.0).unwrap_err().code, "invalid_args");
    }

    #[test]
    fn edit_pours_writes_an_older_files_net_number_and_name() {
        let t = "(kicad_pcb (version 20240108) (net 0 \"\") (net 1 \"GND\") (net 2 \"VCC\")\n  (zone (net 1) (net_name \"GND\") (layer \"F.Cu\") (tstamp \"z9\") (hatch edge 0.5) (connect_pads (clearance 0.2)) (min_thickness 0.25) (fill yes (thermal_gap 0.5) (thermal_bridge_width 0.5)) (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1))))\n)\n";
        let c = edit_pours(t, &json!({"pours": [{"id": "z9", "net": "VCC"}]})).unwrap();
        assert!(c.candidate.contains("(net 2) (net_name \"VCC\")"), "{}", c.candidate);
        assert_eq!(c.report["edited"][0]["after"]["net"], "VCC");
    }

    #[test]
    fn plan_refs_takes_the_next_free_numbers_and_refuses_a_taken_one() {
        let t = "(kicad_pcb (footprint \"a\" (layer \"F.Cu\") (property \"Reference\" \"R1\")) (footprint \"a\" (layer \"F.Cu\") (property \"Reference\" \"R3\")))";
        let r = sxedit::parse(t).unwrap();
        assert_eq!(plan_refs(&r, "R", 3).unwrap(), vec!["R2", "R4", "R5"]);
        assert_eq!(plan_refs(&r, "R7", 2).unwrap(), vec!["R7", "R8"]);
        assert_eq!(plan_refs(&r, "R3", 1).unwrap_err().code, "duplicate_reference");
        assert_eq!(mounting_hole_lib_id(3.2, false).as_deref(), Some("MountingHole:MountingHole_3.2mm_M3"));
        assert_eq!(mounting_hole_lib_id(3.2, true).as_deref(), Some("MountingHole:MountingHole_3.2mm_M3_Pad"));
        assert_eq!(mounting_hole_lib_id(3.0, false), None);
    }

    #[test]
    fn a_pads_changes_are_named_the_standards_way() {
        let a = "smd roundrect at(1.0000,0.0000,0.0000) size(0.6000,0.8000) drill() layers(F.Cu F.Paste F.Mask)";
        let b = "smd roundrect at(1.0000,0.0000,90.0000) size(2.0000,0.9000) drill() layers(F.Cu F.Paste F.Mask)";
        assert_eq!(super::pad_changes(a, b), vec!["rotation".to_string(), "size".to_string()]);
        assert_eq!(super::lib_name("Package_SO:SOIC-8"), ("Package_SO".to_string(), "SOIC-8".to_string()));
    }
}