← Commit history

1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schematic: components, pins, items, rotate, region move, annotate, replace and update symbols, field reset, wire delete/split, labels, junctions, net stubs, pin-to-pin wiring, text, buses, atomic batches, sheets and hierarchy, sheet pins, connectivity trace, checks, connectivity fixes, PNG render with visual baselines, design review. Board: outline, layer count, graphics, mounting holes, SVG import, refill, save, add/flip/delete/edit/align footprints, update footprints from library, footprint graphics and 3D models, selection and cross-probe, update PCB from schematic. Routing and rules: modify track, diff pairs, copy routing, netclasses, board rules, custom rules, clearance check, placement score, decoupling placement, fanout plan. Fab: DXF, GenCAD, IPC-2581, ODB++, IPC-D-356, one-zip fab package, fab pre-flight, cost numbers. Projects and libraries: create, info, rename, snapshot; footprint and symbol create, edit, info; library search. PCM: file:// and private wiki repositories, zipPath installs (#132).

John Lauer ·0009b41dd4 ·14d ago ·parent 9eba82a
19 files changed +8626−4
rust/crates/kicad-core/src/dfm.rsadded+969
@@ -0,0 +1,969 @@+//! Manufacturability pre-flight from the board file (kicad_validate_for_fab): the outline,+//! copper widths and gaps, drills and rings, copper near the edge, silkscreen on pads,+//! vias in pads, acid traps, footprints without 3D models and missing fiducials, checked+//! against a fab profile. KiCad's own DRC (run by the verb layer) is folded in as one more+//! finding; this module is pure geometry and is tested without KiCad.+//!+//! Every copper item is modelled as a stadium (a segment swept by a radius): a track is+//! exact, a via or round pad is exact, a rectangular pad is approximated by the stadium+//! along its long axis (corners slightly rounded off), so gaps near rectangle corners are+//! reported a little larger than they are. KiCad's DRC remains the authority on spacing.++use std::collections::{BTreeMap, HashMap};++use serde_json::{json, Value};++use crate::model_check::{parse_sexpr, Sx};+use crate::pcb::{arc_points, Point};++pub type P = (f64, f64);++#[derive(Clone, Debug)]+pub struct Stadium {+    pub a: P,+    pub b: P,+    pub r: f64,+    /// Copper (or silkscreen) layer names; empty means "every copper layer".+    pub layers: Vec<String>,+    pub net: String,+    /// "R1.2" for a pad, "track", "via", or the footprint reference for silkscreen.+    pub owner: String,+    /// Footprint reference for pads and footprint silkscreen.+    pub fp: String,+    pub smd: bool,+}++impl Stadium {+    fn bbox(&self) -> (f64, f64, f64, f64) {+        (self.a.0.min(self.b.0) - self.r, self.a.1.min(self.b.1) - self.r, self.a.0.max(self.b.0) + self.r, self.a.1.max(self.b.1) + self.r)+    }+    fn on(&self, layer: &str) -> bool {+        self.layers.is_empty() || self.layers.iter().any(|l| l == layer)+    }+    fn shares_layer(&self, o: &Stadium) -> bool {+        if self.layers.is_empty() || o.layers.is_empty() {+            return true;+        }+        self.layers.iter().any(|l| o.on(l))+    }+}++#[derive(Clone, Debug)]+pub struct Hole {+    pub at: P,+    pub drill: f64,+    /// Copper diameter around it (0 for a non-plated hole).+    pub ring_dia: f64,+    pub via: bool,+    pub plated: bool,+    pub owner: String,+}++#[derive(Clone, Debug, Default)]+pub struct FpInfo {+    pub reference: String,+    pub footprint: String,+    pub value: String,+    pub attrs: Vec<String>,+    pub has_model: bool,+}++#[derive(Clone, Debug, Default)]+pub struct Geometry {+    pub copper_layers: Vec<String>,+    pub thickness: f64,+    pub tracks: Vec<Stadium>,+    pub vias: Vec<Stadium>,+    pub pads: Vec<Stadium>,+    pub holes: Vec<Hole>,+    /// Edge.Cuts as straight pieces (arcs and circles as chords).+    pub edges: Vec<(P, P)>,+    /// Endpoints of the open Edge.Cuts primitives (lines, arcs), for the closure check.+    pub open_ends: Vec<P>,+    pub closed_shapes: usize,+    pub silk: Vec<Stadium>,+    pub footprints: Vec<FpInfo>,+}++fn f(sx: &Sx, i: usize) -> Option<f64> {+    sx.atom_at(i)?.parse().ok()+}++fn pt(n: Option<&Sx>) -> Option<P> {+    let n = n?;+    Some((f(n, 1)?, f(n, 2)?))+}++fn layer_of(n: &Sx) -> Option<&str> {+    n.find("layer").and_then(Sx::value)+}++fn stroke_width(n: &Sx) -> f64 {+    n.find("stroke").and_then(|s| s.find("width")).and_then(|w| f(w, 1)).or_else(|| n.find("width").and_then(|w| f(w, 1))).unwrap_or(0.0)+}++/// Footprint-local to board: KiCad's y-down rotation (see `pcb::pads`).+#[derive(Clone, Copy)]+struct Xf {+    x: f64,+    y: f64,+    sin: f64,+    cos: f64,+}++impl Xf {+    fn id() -> Xf {+        Xf { x: 0.0, y: 0.0, sin: 0.0, cos: 1.0 }+    }+    fn of(fp: &Sx) -> Xf {+        let at = fp.find("at");+        let rot = at.and_then(|a| f(a, 3)).unwrap_or(0.0).to_radians();+        Xf { x: at.and_then(|a| f(a, 1)).unwrap_or(0.0), y: at.and_then(|a| f(a, 2)).unwrap_or(0.0), sin: rot.sin(), cos: rot.cos() }+    }+    fn p(&self, q: P) -> P {+        (self.x + q.0 * self.cos + q.1 * self.sin, self.y - q.0 * self.sin + q.1 * self.cos)+    }+}++/// The straight pieces of one graphic (line, rect, poly, circle, arc), transformed, and+/// whether the shape closes on itself.+fn shape_pieces(n: &Sx, xf: Xf) -> Option<(Vec<(P, P)>, bool, Option<(P, P)>)> {+    let tag = n.tag()?;+    let kind = tag.trim_start_matches("gr_").trim_start_matches("fp_");+    let mut out = Vec::new();+    match kind {+        "line" => {+            let (a, b) = (pt(n.find("start"))?, pt(n.find("end"))?);+            out.push((xf.p(a), xf.p(b)));+            Some((out, false, Some((xf.p(a), xf.p(b)))))+        }+        "arc" => {+            let (a, m, b) = (pt(n.find("start"))?, pt(n.find("mid"))?, pt(n.find("end"))?);+            let pts = arc_points(&Point { x: a.0, y: a.1 }, &Point { x: m.0, y: m.1 }, &Point { x: b.0, y: b.1 });+            for w in pts.windows(2) {+                out.push((xf.p((w[0].x, w[0].y)), xf.p((w[1].x, w[1].y))));+            }+            Some((out, false, Some((xf.p(a), xf.p(b)))))+        }+        "rect" => {+            let (a, b) = (pt(n.find("start"))?, pt(n.find("end"))?);+            let c = [(a.0, a.1), (b.0, a.1), (b.0, b.1), (a.0, b.1)];+            for i in 0..4 {+                out.push((xf.p(c[i]), xf.p(c[(i + 1) % 4])));+            }+            Some((out, true, None))+        }+        "circle" => {+            let (c, e) = (pt(n.find("center"))?, pt(n.find("end"))?);+            let r = dist(c, e);+            let k = 72;+            let at = |i: usize| {+                let t = std::f64::consts::TAU * i as f64 / k as f64;+                xf.p((c.0 + r * t.cos(), c.1 + r * t.sin()))+            };+            for i in 0..k {+                out.push((at(i), at(i + 1)));+            }+            Some((out, true, None))+        }+        "poly" => {+            let v: Vec<P> = n.find("pts")?.find_all("xy").filter_map(|xy| Some(xf.p((f(xy, 1)?, f(xy, 2)?)))).collect();+            if v.len() < 2 {+                return None;+            }+            for i in 0..v.len() {+                out.push((v[i], v[(i + 1) % v.len()]));+            }+            Some((out, true, None))+        }+        _ => None,+    }+}++impl Geometry {+    pub fn from_text(text: &str) -> Result<Geometry, String> {+        let tree = parse_sexpr(text).ok_or("empty board file")?;+        if tree.tag() != Some("kicad_pcb") {+            return Err("not a kicad_pcb file".into());+        }+        let mut g = Geometry {+            thickness: tree.find("general").and_then(|g| g.find("thickness")).and_then(|t| f(t, 1)).unwrap_or(1.6),+            ..Default::default()+        };+        if let Some(layers) = tree.find("layers").and_then(Sx::as_list) {+            for l in layers.iter().skip(1) {+                if let Some(name) = l.atom_at(1) {+                    if name.ends_with(".Cu") {+                        g.copper_layers.push(name.to_string());+                    }+                }+            }+        }+        let net_of = |n: &Sx| -> String {+            let Some(net) = n.find("net") else { return String::new() };+            // KiCad <= 9: (net 3 "GND"); KiCad 10: (net "GND").+            match (net.atom_at(1), net.atom_at(2)) {+                (Some(_), Some(name)) => name.to_string(),+                (Some(a), None) if a.parse::<i64>().is_err() => a.to_string(),+                (Some(a), None) => format!("#{a}"),+                _ => String::new(),+            }+        };+        let net_of = &net_of;+        for (tag, is_arc) in [("segment", false), ("arc", true)] {+            for t in tree.find_all(tag) {+                let w = t.find("width").and_then(|w| f(w, 1)).unwrap_or(0.0);+                let layer = layer_of(t).unwrap_or("F.Cu").to_string();+                let net = net_of(t);+                let pieces: Vec<(P, P)> = if is_arc {+                    match (pt(t.find("start")), pt(t.find("mid")), pt(t.find("end"))) {+                        (Some(a), Some(m), Some(b)) => arc_points(&Point { x: a.0, y: a.1 }, &Point { x: m.0, y: m.1 }, &Point { x: b.0, y: b.1 }).windows(2).map(|w| ((w[0].x, w[0].y), (w[1].x, w[1].y))).collect(),+                        _ => continue,+                    }+                } else {+                    match (pt(t.find("start")), pt(t.find("end"))) {+                        (Some(a), Some(b)) => vec![(a, b)],+                        _ => continue,+                    }+                };+                for (a, b) in pieces {+                    g.tracks.push(Stadium { a, b, r: w / 2.0, layers: vec![layer.clone()], net: net.clone(), owner: "track".into(), fp: String::new(), smd: false });+                }+            }+        }+        for v in tree.find_all("via") {+            let Some(at) = pt(v.find("at")) else { continue };+            let size = v.find("size").and_then(|s| f(s, 1)).unwrap_or(0.0);+            let drill = v.find("drill").and_then(|s| f(s, 1)).unwrap_or(0.0);+            g.vias.push(Stadium { a: at, b: at, r: size / 2.0, layers: Vec::new(), net: net_of(v), owner: "via".into(), fp: String::new(), smd: false });+            g.holes.push(Hole { at, drill, ring_dia: size, via: true, plated: true, owner: format!("via at ({}, {})", fmt(at.0), fmt(at.1)) });+        }+        for n in tree.as_list().unwrap_or(&[]) {+            let Some(tag) = n.tag() else { continue };+            if !tag.starts_with("gr_") {+                continue;+            }+            g.add_graphic(n, Xf::id(), "");+        }+        for fp in tree.find_all("footprint") {+            let xf = Xf::of(fp);+            let mut reference = String::new();+            let mut value = String::new();+            for p in fp.find_all("property") {+                match p.atom_at(1) {+                    Some("Reference") => reference = p.atom_at(2).unwrap_or("").to_string(),+                    Some("Value") => value = p.atom_at(2).unwrap_or("").to_string(),+                    _ => {}+                }+            }+            if reference.is_empty() {+                // KiCad 7 and older: (fp_text reference "R1")+                if let Some(t) = fp.find_all("fp_text").find(|t| t.atom_at(1) == Some("reference")) {+                    reference = t.atom_at(2).unwrap_or("").to_string();+                }+            }+            let attrs: Vec<String> = fp.find("attr").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default();+            g.footprints.push(FpInfo { reference: reference.clone(), footprint: fp.atom_at(1).unwrap_or("").to_string(), value, attrs, has_model: fp.find("model").is_some() });+            for child in fp.as_list().unwrap_or(&[]) {+                if child.tag().map(|t| t.starts_with("fp_")).unwrap_or(false) {+                    g.add_graphic(child, xf, &reference);+                }+            }+            for pad in fp.find_all("pad") {+                let kind = pad.atom_at(2).unwrap_or("");+                let Some(local) = pt(pad.find("at")) else { continue };+                let ang = pad.find("at").and_then(|a| f(a, 3)).unwrap_or(0.0).to_radians();+                let c = xf.p(local);+                let (w, h) = pad.find("size").map(|s| (f(s, 1).unwrap_or(0.0), f(s, 2).unwrap_or(0.0))).unwrap_or((0.0, 0.0));+                let layers: Vec<String> = pad.find("layers").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default();+                let copper: Vec<String> = if layers.iter().any(|l| l == "*.Cu") {+                    Vec::new()+                } else {+                    layers.iter().filter(|l| l.ends_with(".Cu")).cloned().collect()+                };+                let drill = pad.find("drill").and_then(|d| {+                    let nums: Vec<f64> = d.as_list().unwrap_or(&[]).iter().skip(1).filter_map(Sx::as_atom).filter_map(|a| a.parse().ok()).collect();+                    nums.iter().cloned().fold(None, |m: Option<f64>, x| Some(m.map_or(x, |m| m.min(x))))+                });+                let number = pad.atom_at(1).unwrap_or("").to_string();+                let owner = format!("{reference}.{number}");+                if kind == "np_thru_hole" {+                    if let Some(d) = drill {+                        g.holes.push(Hole { at: c, drill: d, ring_dia: 0.0, via: false, plated: false, owner: owner.clone() });+                    }+                    // A non-plated hole carries no copper unless its size exceeds the drill.+                    if w <= drill.unwrap_or(0.0) + 1e-6 {+                        continue;+                    }+                }+                if kind == "thru_hole" {+                    if let Some(d) = drill {+                        g.holes.push(Hole { at: c, drill: d, ring_dia: w.min(h), via: false, plated: true, owner: owner.clone() });+                    }+                }+                let has_copper = copper.is_empty() && layers.iter().any(|l| l == "*.Cu") || !copper.is_empty();+                if !has_copper {+                    continue;+                }+                let (long, short) = if w >= h { (w, h) } else { (h, w) };+                let half = (long - short) / 2.0;+                // the long axis in board coordinates: pad angle is absolute, y down+                let axis_ang = if w >= h { ang } else { ang + std::f64::consts::FRAC_PI_2 };+                let d = (axis_ang.cos() * half, -axis_ang.sin() * half);+                g.pads.push(Stadium {+                    a: (c.0 - d.0, c.1 - d.1),+                    b: (c.0 + d.0, c.1 + d.1),+                    r: short / 2.0,+                    layers: copper,+                    net: net_of(pad),+                    owner,+                    fp: reference.clone(),+                    smd: kind == "smd",+                });+            }+        }+        Ok(g)+    }++    fn add_graphic(&mut self, n: &Sx, xf: Xf, fp: &str) {+        let Some(layer) = layer_of(n) else { return };+        let silk = matches!(layer, "F.SilkS" | "B.SilkS" | "F.Silkscreen" | "B.Silkscreen");+        if layer != "Edge.Cuts" && !silk {+            return;+        }+        let Some((pieces, closed, ends)) = shape_pieces(n, xf) else { return };+        if layer == "Edge.Cuts" {+            if closed {+                self.closed_shapes += 1;+            } else if let Some((a, b)) = ends {+                self.open_ends.push(a);+                self.open_ends.push(b);+            }+            self.edges.extend(pieces);+        } else {+            let side = if layer.starts_with('F') { "F.Cu" } else { "B.Cu" };+            let r = stroke_width(n) / 2.0;+            for (a, b) in pieces {+                self.silk.push(Stadium { a, b, r, layers: vec![side.into()], net: String::new(), owner: fp.to_string(), fp: fp.to_string(), smd: false });+            }+        }+    }++    /// (closed, dangling endpoints). Open pieces must meet end to end within 10 um.+    pub fn outline_closed(&self) -> (bool, Vec<P>) {+        if self.edges.is_empty() {+            return (false, Vec::new());+        }+        let key = |p: P| ((p.0 * 100.0).round() as i64, (p.1 * 100.0).round() as i64);+        let mut deg: HashMap<(i64, i64), (usize, P)> = HashMap::new();+        for &p in &self.open_ends {+            let e = deg.entry(key(p)).or_insert((0, p));+            e.0 += 1;+        }+        let mut dangling: Vec<P> = deg.values().filter(|(d, _)| d % 2 == 1).map(|(_, p)| *p).collect();+        dangling.sort_by(|a, b| a.partial_cmp(b).unwrap());+        (dangling.is_empty(), dangling)+    }++    pub fn outline_size(&self) -> Option<(f64, f64)> {+        if self.edges.is_empty() {+            return None;+        }+        let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN);+        for (a, b) in &self.edges {+            for p in [a, b] {+                x0 = x0.min(p.0);+                y0 = y0.min(p.1);+                x1 = x1.max(p.0);+                y1 = y1.max(p.1);+            }+        }+        Some((x1 - x0, y1 - y0))+    }++    pub fn min_track(&self) -> Option<f64> {+        self.tracks.iter().map(|t| t.r * 2.0).filter(|w| *w > 0.0).fold(None, |m, w| Some(m.map_or(w, |m: f64| m.min(w))))+    }++    fn copper(&self) -> Vec<&Stadium> {+        self.tracks.iter().chain(self.vias.iter()).chain(self.pads.iter()).collect()+    }++    /// Every copper gap between items of different nets on a shared layer, smallest first+    /// (gap, item, item, where). Pads of one footprint are not paired (the land pattern's+    /// own pitch is the part maker's call). Items with no net are skipped.+    pub fn gaps(&self, below: f64) -> Vec<(f64, String, String, P)> {+        let items = self.copper();+        let boxes: Vec<_> = items.iter().map(|s| s.bbox()).collect();+        let mut out = Vec::new();+        for (i, j) in near_pairs(&boxes, &boxes, below, true) {+            let (a, b) = (items[i], items[j]);+            if a.net.is_empty() || b.net.is_empty() || a.net == b.net || !a.shares_layer(b) {+                continue;+            }+            if !a.fp.is_empty() && a.fp == b.fp {+                continue;+            }+            let (d, at) = seg_seg(a.a, a.b, b.a, b.b);+            let gap = d - a.r - b.r;+            if gap < below {+                out.push((gap, label(a), label(b), at));+            }+        }+        out.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap());+        out+    }++    /// The smallest copper gap found (within 1 mm), if any.+    pub fn min_space(&self) -> Option<(f64, String, String, P)> {+        self.gaps(1.0).into_iter().next()+    }++    /// Copper closer than `limit` to the board edge, smallest first.+    pub fn edge_gaps(&self, limit: f64) -> Vec<(f64, String, P)> {+        let items = self.copper();+        let boxes: Vec<_> = items.iter().map(|s| s.bbox()).collect();+        let eboxes: Vec<_> = self.edges.iter().map(|(a, b)| (a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1))).collect();+        let mut best: BTreeMap<usize, (f64, P)> = BTreeMap::new();+        for (i, j) in near_pairs(&boxes, &eboxes, limit, false) {+            let s = items[i];+            let (a, b) = self.edges[j];+            let (d, at) = seg_seg(s.a, s.b, a, b);+            let gap = d - s.r;+            if gap < limit {+                let e = best.entry(i).or_insert((gap, at));+                if gap < e.0 {+                    *e = (gap, at);+                }+            }+        }+        let mut out: Vec<(f64, String, P)> = best.into_iter().map(|(i, (g, at))| (g, label(items[i]), at)).collect();+        out.sort_by(|x, y| x.0.partial_cmp(&y.0).unwrap());+        out+    }++    /// Silkscreen strokes that land on a pad of the same side: (footprint ref or "board", pad).+    pub fn silk_on_pads(&self) -> Vec<(String, String, P)> {+        let sb: Vec<_> = self.silk.iter().map(|s| s.bbox()).collect();+        let pb: Vec<_> = self.pads.iter().map(|s| s.bbox()).collect();+        let mut seen = std::collections::BTreeSet::new();+        let mut out = Vec::new();+        for (i, j) in near_pairs(&sb, &pb, 0.0, false) {+            let (s, p) = (&self.silk[i], &self.pads[j]);+            if !p.on(&s.layers[0]) {+                continue;+            }+            let (d, at) = seg_seg(s.a, s.b, p.a, p.b);+            if d < s.r + p.r - 1e-6 && seen.insert((s.owner.clone(), p.owner.clone())) {+                out.push((if s.owner.is_empty() { "board".into() } else { s.owner.clone() }, p.owner.clone(), at));+            }+        }+        out.sort_by(|a, b| a.1.cmp(&b.1));+        out.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);+        out+    }++    /// Vias whose centre lies inside an SMD pad: (via, pad).+    pub fn vias_in_pads(&self) -> Vec<(String, String, P)> {+        let vb: Vec<_> = self.vias.iter().map(|s| s.bbox()).collect();+        let pb: Vec<_> = self.pads.iter().map(|s| s.bbox()).collect();+        let mut out = Vec::new();+        for (i, j) in near_pairs(&vb, &pb, 0.0, false) {+            let (v, p) = (&self.vias[i], &self.pads[j]);+            if !p.smd {+                continue;+            }+            let (d, _) = seg_seg(v.a, v.a, p.a, p.b);+            if d < p.r {+                out.push((format!("via at ({}, {})", fmt(v.a.0), fmt(v.a.1)), p.owner.clone(), v.a));+            }+        }+        out+    }++    /// Track joints where two tracks of one net meet at an acute angle (under 90 degrees),+    /// away from pads and vias that would fill the wedge: (angle, layer, net, where).+    pub fn acid_traps(&self) -> Vec<(f64, String, String, P)> {+        let key = |p: P| ((p.0 * 1000.0).round() as i64, (p.1 * 1000.0).round() as i64);+        let mut joints: HashMap<(String, String, (i64, i64)), Vec<P>> = HashMap::new();+        for t in &self.tracks {+            if dist(t.a, t.b) < 1e-6 {+                continue;+            }+            let layer = t.layers.first().cloned().unwrap_or_default();+            joints.entry((layer.clone(), t.net.clone(), key(t.a))).or_default().push((t.b.0 - t.a.0, t.b.1 - t.a.1));+            joints.entry((layer, t.net.clone(), key(t.b))).or_default().push((t.a.0 - t.b.0, t.a.1 - t.b.1));+        }+        let mut out = Vec::new();+        for ((layer, net, k), dirs) in joints {+            if dirs.len() < 2 {+                continue;+            }+            let at = (k.0 as f64 / 1000.0, k.1 as f64 / 1000.0);+            let covered = self.pads.iter().chain(self.vias.iter()).any(|p| p.on(&layer) && seg_seg(at, at, p.a, p.b).0 <= p.r + 1e-6);+            if covered {+                continue;+            }+            let mut worst: Option<f64> = None;+            for i in 0..dirs.len() {+                for j in i + 1..dirs.len() {+                    let ang = angle_between(dirs[i], dirs[j]);+                    if ang > 1.0 && ang < 89.5 {+                        worst = Some(worst.map_or(ang, |w: f64| w.min(ang)));+                    }+                }+            }+            if let Some(a) = worst {+                out.push(((a * 10.0).round() / 10.0, layer, net, at));+            }+        }+        out.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap().then(a.3.partial_cmp(&b.3).unwrap()));+        out+    }+}++fn label(s: &Stadium) -> String {+    match s.owner.as_str() {+        "track" => format!("track on {} ({})", s.layers.first().map(String::as_str).unwrap_or("?"), s.net),+        "via" => format!("via ({})", s.net),+        o => format!("pad {o} ({})", s.net),+    }+}++pub fn fmt(v: f64) -> String {+    crate::schematic::fmt_num(v)+}++pub fn dist(a: P, b: P) -> f64 {+    ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()+}++fn angle_between(u: P, v: P) -> f64 {+    let d = (u.0 * v.0 + u.1 * v.1) / ((u.0.hypot(u.1)) * (v.0.hypot(v.1)));+    d.clamp(-1.0, 1.0).acos().to_degrees()+}++/// Closest point on segment ab to p.+fn closest(p: P, a: P, b: P) -> P {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l2 = dx * dx + dy * dy;+    if l2 < 1e-18 {+        return a;+    }+    let t = (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0);+    (a.0 + t * dx, a.1 + t * dy)+}++fn cross(o: P, a: P, b: P) -> f64 {+    (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0)+}++/// Distance between segments ab and cd, and a point between them where it is reached.+pub fn seg_seg(a: P, b: P, c: P, d: P) -> (f64, P) {+    let d1 = cross(c, d, a);+    let d2 = cross(c, d, b);+    let d3 = cross(a, b, c);+    let d4 = cross(a, b, d);+    if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) {+        let t = d1 / (d1 - d2);+        return (0.0, (a.0 + t * (b.0 - a.0), a.1 + t * (b.1 - a.1)));+    }+    let cands = [(a, closest(a, c, d)), (b, closest(b, c, d)), (closest(c, a, b), c), (closest(d, a, b), d)];+    let mut best = (f64::MAX, a);+    for (p, q) in cands {+        let dd = dist(p, q);+        if dd < best.0 {+            best = (dd, ((p.0 + q.0) / 2.0, (p.1 + q.1) / 2.0));+        }+    }+    best+}++type BBox = (f64, f64, f64, f64);++/// Index pairs (i in a, j in b) whose boxes come within `margin`, through a uniform grid.+/// With `same` (a and b are one list) each unordered pair is returned once, i < j.+fn near_pairs(a: &[BBox], b: &[BBox], margin: f64, same: bool) -> Vec<(usize, usize)> {+    let cell = 2.0f64.max(margin * 2.0);+    let ci = |v: f64| (v / cell).floor() as i64;+    let mut grid: HashMap<(i64, i64), Vec<usize>> = HashMap::new();+    for (j, bb) in b.iter().enumerate() {+        for gx in ci(bb.0)..=ci(bb.2) {+            for gy in ci(bb.1)..=ci(bb.3) {+                grid.entry((gx, gy)).or_default().push(j);+            }+        }+    }+    let mut seen = std::collections::HashSet::new();+    let mut out = Vec::new();+    for (i, ab) in a.iter().enumerate() {+        let e = (ab.0 - margin, ab.1 - margin, ab.2 + margin, ab.3 + margin);+        for gx in ci(e.0)..=ci(e.2) {+            for gy in ci(e.1)..=ci(e.3) {+                let Some(list) = grid.get(&(gx, gy)) else { continue };+                for &j in list {+                    if same && j <= i {+                        continue;+                    }+                    let bb = b[j];+                    if bb.0 > e.2 || bb.2 < e.0 || bb.1 > e.3 || bb.3 < e.1 {+                        continue;+                    }+                    if seen.insert((i, j)) {+                        out.push((i, j));+                    }+                }+            }+        }+    }+    out+}++// ---------------------------------------------------------------------------+// Fab profiles+// ---------------------------------------------------------------------------++#[derive(Clone, Debug, PartialEq)]+pub struct Profile {+    pub name: String,+    pub min_track: f64,+    pub min_space: f64,+    pub min_drill: f64,+    pub min_annular: f64,+    pub min_edge: f64,+    pub layer_counts: Vec<u32>,+    pub source: Vec<String>,+}++pub const PROFILES: &[&str] = &["generic", "jlcpcb"];++/// The bridge's built-in approximations. A staged `<stem>.kicad_dru` and explicit args win.+pub fn builtin_profile(name: &str) -> Option<Profile> {+    match name {+        "generic" => Some(Profile {+            name: "generic".into(),+            min_track: 0.15,+            min_space: 0.15,+            min_drill: 0.3,+            min_annular: 0.13,+            min_edge: 0.3,+            layer_counts: (1..=32).collect(),+            source: vec!["built-in generic profile (6 mil track and space, 0.3 mm drill, 0.13 mm ring, 0.3 mm copper to edge)".into()],+        }),+        "jlcpcb" => Some(Profile {+            name: "jlcpcb".into(),+            min_track: 0.127,+            min_space: 0.127,+            min_drill: 0.3,+            min_annular: 0.075,+            min_edge: 0.3,+            layer_counts: vec![1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20],+            source: vec!["built-in JLCPCB standard-process approximation (5 mil track and space, 0.3 mm drill, 0.075 mm via ring, 0.3 mm copper to edge)".into()],+        }),+        _ => None,+    }+}++/// A KiCad length: "0.127mm", "5mil", "0.005in", or a bare number in mm.+pub fn dru_length(s: &str) -> Option<f64> {+    let t = s.trim();+    let (num, mul) = if let Some(n) = t.strip_suffix("mm") {+        (n, 1.0)+    } else if let Some(n) = t.strip_suffix("mil") {+        (n, 0.0254)+    } else if let Some(n) = t.strip_suffix("in") {+        (n, 25.4)+    } else if let Some(n) = t.strip_suffix("um") {+        (n, 0.001)+    } else {+        (t, 1.0)+    };+    num.trim().parse::<f64>().ok().map(|v| v * mul)+}++/// The most permissive `min` of each constraint in a .kicad_dru (KiCad custom rules):+/// track_width, clearance, hole_size, annular_width, edge_clearance.+pub fn dru_minimums(text: &str) -> BTreeMap<String, f64> {+    let cleaned: String = text.lines().filter(|l| !l.trim_start().starts_with('#')).collect::<Vec<_>>().join("\n");+    let mut out: BTreeMap<String, f64> = BTreeMap::new();+    let Some(root) = parse_sexpr(&format!("(dru {cleaned})")) else { return out };+    for rule in root.find_all("rule") {+        for c in rule.find_all("constraint") {+            let Some(kind) = c.atom_at(1) else { continue };+            let Some(min) = c.find("min").and_then(|m| m.atom_at(1)).and_then(dru_length) else { continue };+            let e = out.entry(kind.to_string()).or_insert(min);+            if min < *e {+                *e = min;+            }+        }+    }+    out+}++/// Apply a .kicad_dru's minimums onto a profile.+pub fn apply_dru(p: &mut Profile, text: &str, source: &str) -> usize {+    let m = dru_minimums(text);+    let mut n = 0;+    for (k, slot) in [("track_width", &mut p.min_track), ("clearance", &mut p.min_space), ("hole_size", &mut p.min_drill), ("annular_width", &mut p.min_annular), ("edge_clearance", &mut p.min_edge)] {+        if let Some(v) = m.get(k) {+            *slot = *v;+            n += 1;+        }+    }+    if n > 0 {+        p.source.push(format!("{n} minimum(s) from {source}"));+    }+    n+}++// ---------------------------------------------------------------------------+// The pre-flight+// ---------------------------------------------------------------------------++pub struct Options {+    pub assembly: bool,+    pub max_items: usize,+}++fn finding(check: &str, severity: &str, message: String, items: Vec<Value>, max: usize) -> Value {+    let count = items.len();+    json!({"check": check, "severity": severity, "message": message, "count": count, "items": items.into_iter().take(max).collect::<Vec<_>>()})+}++fn at(p: P) -> Value {+    json!({"x": (p.0 * 1000.0).round() / 1000.0, "y": (p.1 * 1000.0).round() / 1000.0})+}++/// Run every board-file check. `drc` is kicad-cli's raw DRC report when it ran, or a+/// reason it did not. Returns {pass, errors, warnings, findings, checked, profile}.+pub fn validate(g: &Geometry, p: &Profile, opts: &Options, drc: Result<&Value, &str>) -> Value {+    let max = opts.max_items.max(1);+    let mut findings: Vec<Value> = Vec::new();+    let mut checked: Vec<&str> = Vec::new();++    checked.push("outline");+    let (closed, dangling) = g.outline_closed();+    if g.edges.is_empty() {+        findings.push(finding("outline", "error", "No board outline: nothing on Edge.Cuts.".into(), vec![], max));+    } else if !closed {+        findings.push(finding("outline", "error", format!("The Edge.Cuts outline is not closed: {} loose end(s).", dangling.len()), dangling.iter().map(|d| at(*d)).collect(), max));+    }++    checked.push("layer_count");+    let n = g.copper_layers.len() as u32;+    if !p.layer_counts.contains(&n) {+        findings.push(finding("layer_count", "error", format!("{n} copper layers; the {} profile builds {:?}.", p.name, p.layer_counts), vec![], max));+    }++    checked.push("min_track");+    let thin: Vec<&Stadium> = g.tracks.iter().filter(|t| t.r * 2.0 < p.min_track - 1e-6).collect();+    if !thin.is_empty() {+        let w = thin.iter().map(|t| t.r * 2.0).fold(f64::MAX, f64::min);+        findings.push(finding("min_track", "error", format!("{} track piece(s) narrower than {} mm (narrowest {} mm).", thin.len(), fmt(p.min_track), fmt(w)), thin.iter().map(|t| json!({"width": t.r * 2.0, "layer": t.layers.first(), "net": t.net, "at": at(t.a)})).collect(), max));+    }++    checked.push("min_space");+    let gaps = g.gaps(p.min_space - 1e-6);+    if !gaps.is_empty() {+        findings.push(finding("min_space", "error", format!("{} copper gap(s) under {} mm (smallest {} mm).", gaps.len(), fmt(p.min_space), fmt(gaps[0].0.max(0.0))), gaps.iter().map(|(gap, a, b, w)| json!({"gap": (gap.max(0.0) * 1000.0).round() / 1000.0, "between": [a, b], "at": at(*w)})).collect(), max));+    }++    checked.push("min_drill");+    let small: Vec<&Hole> = g.holes.iter().filter(|h| h.drill > 0.0 && h.drill < p.min_drill - 1e-6).collect();+    if !small.is_empty() {+        findings.push(finding("min_drill", "error", format!("{} hole(s) smaller than {} mm.", small.len(), fmt(p.min_drill)), small.iter().map(|h| json!({"drill": h.drill, "owner": h.owner, "at": at(h.at)})).collect(), max));+    }++    checked.push("annular_ring");+    let rings: Vec<&Hole> = g.holes.iter().filter(|h| h.plated && h.ring_dia > 0.0 && (h.ring_dia - h.drill) / 2.0 < p.min_annular - 1e-6).collect();+    if !rings.is_empty() {+        findings.push(finding("annular_ring", "error", format!("{} plated hole(s) with a copper ring under {} mm.", rings.len(), fmt(p.min_annular)), rings.iter().map(|h| json!({"ring": ((h.ring_dia - h.drill) / 2.0 * 1000.0).round() / 1000.0, "owner": h.owner, "at": at(h.at)})).collect(), max));+    }++    checked.push("edge_clearance");+    if !g.edges.is_empty() {+        let near = g.edge_gaps(p.min_edge - 1e-6);+        let crossing = near.iter().filter(|x| x.0 < 0.0).count();+        if !near.is_empty() {+            findings.push(finding(+                "edge_clearance",+                if crossing > 0 { "error" } else { "warning" },+                format!("{} copper item(s) closer than {} mm to the board edge{}.", near.len(), fmt(p.min_edge), if crossing > 0 { format!(", {crossing} over it") } else { String::new() }),+                near.iter().map(|(gap, who, w)| json!({"gap": (gap * 1000.0).round() / 1000.0, "item": who, "at": at(*w)})).collect(),+                max,+            ));+        }+    }++    checked.push("silk_over_pads");+    let silk = g.silk_on_pads();+    if !silk.is_empty() {+        findings.push(finding("silk_over_pads", "warning", format!("{} silkscreen stroke(s) on exposed pads (the fab clips them, or they stop solder wetting).", silk.len()), silk.iter().map(|(who, pad, w)| json!({"silk": who, "pad": pad, "at": at(*w)})).collect(), max));+    }++    checked.push("via_in_pad");+    let vip = g.vias_in_pads();+    if !vip.is_empty() {+        findings.push(finding("via_in_pad", "warning", format!("{} via(s) inside SMD pads: order filled and capped vias, or solder wicks down the hole.", vip.len()), vip.iter().map(|(v, pad, w)| json!({"via": v, "pad": pad, "at": at(*w)})).collect(), max));+    }++    checked.push("acid_traps");+    let traps = g.acid_traps();+    if !traps.is_empty() {+        findings.push(finding("acid_traps", "warning", format!("{} track joint(s) at an acute angle (acid traps).", traps.len()), traps.iter().map(|(a, l, n, w)| json!({"angle": a, "layer": l, "net": n, "at": at(*w)})).collect(), max));+    }++    checked.push("3d_models");+    let no_model: Vec<&FpInfo> = g.footprints.iter().filter(|f| !f.has_model && !f.attrs.iter().any(|a| a == "board_only" || a == "exclude_from_pos_files") && !f.reference.starts_with('#')).collect();+    if !no_model.is_empty() {+        findings.push(finding("3d_models", "info", format!("{} footprint(s) without a 3D model (the STEP export and the 3D check will miss them).", no_model.len()), no_model.iter().map(|f| json!({"ref": f.reference, "footprint": f.footprint})).collect(), max));+    }++    checked.push("fiducials");+    if opts.assembly {+        let fids = g.footprints.iter().filter(|f| f.reference.to_uppercase().starts_with("FID") || f.footprint.to_lowercase().contains("fiducial")).count();+        if fids == 0 {+            findings.push(finding("fiducials", "warning", "Assembly requested but the board has no fiducials. Most assembly lines want three (or panel fiducials on the rails).".into(), vec![], max));+        }+    }++    checked.push("drc");+    match drc {+        Ok(report) => {+            let summary = crate::cli::summarize_report(report);+            let errors = summary["errors"].as_u64().unwrap_or(0);+            let unconnected = summary["unconnected"].as_u64().unwrap_or(0);+            let viol_errors = errors.saturating_sub(unconnected);+            if viol_errors > 0 {+                findings.push(finding("drc", "error", format!("KiCad DRC: {viol_errors} error(s)."), summary["first"].as_array().cloned().unwrap_or_default().into_iter().filter(|v| v["type"] != "unconnected_items").collect(), max));+            }+            if unconnected > 0 {+                findings.push(finding("unconnected", "error", format!("KiCad DRC: {unconnected} unconnected item(s): the board is not fully routed."), vec![], max));+            }+        }+        Err(why) => findings.push(finding("drc", "warning", format!("KiCad DRC did not run ({why}); DRC errors and unconnected items are not checked, so a pass is provisional."), vec![], max)),+    }++    let errors = findings.iter().filter(|f| f["severity"] == "error").count();+    let warnings = findings.iter().filter(|f| f["severity"] == "warning").count();+    json!({+        "pass": errors == 0,+        "errors": errors,+        "warnings": warnings,+        "findings": findings,+        "checked": checked,+        "profile": {"name": p.name, "minTrackMm": p.min_track, "minSpaceMm": p.min_space, "minDrillMm": p.min_drill, "minAnnularMm": p.min_annular, "minEdgeClearanceMm": p.min_edge, "layerCounts": p.layer_counts, "source": p.source},+        "board": {"copperLayers": g.copper_layers.len(), "thicknessMm": g.thickness, "size": g.outline_size().map(|(w, h)| json!({"widthMm": (w * 1000.0).round() / 1000.0, "heightMm": (h * 1000.0).round() / 1000.0})), "footprints": g.footprints.len(), "tracks": g.tracks.len(), "vias": g.vias.len(), "holes": g.holes.len()},+    })+}++#[cfg(test)]+mod tests {+    use super::*;++    fn board(body: &str) -> String {+        format!("(kicad_pcb (version 20260206) (generator \"t\") (general (thickness 1.6)) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal) (25 \"Edge.Cuts\" user) (5 \"F.SilkS\" user \"F.Silkscreen\"))\n{body}\n)")+    }++    const OUTLINE: &str = "(gr_line (start 0 0) (end 40 0) (layer \"Edge.Cuts\")) (gr_line (start 40 0) (end 40 30) (layer \"Edge.Cuts\")) (gr_line (start 40 30) (end 0 30) (layer \"Edge.Cuts\")) (gr_line (start 0 30) (end 0 0) (layer \"Edge.Cuts\"))";++    #[test]+    fn outline_closure_and_size() {+        let g = Geometry::from_text(&board(OUTLINE)).unwrap();+        assert_eq!(g.outline_closed(), (true, vec![]));+        assert_eq!(g.outline_size(), Some((40.0, 30.0)));+        let open = OUTLINE.replace("(gr_line (start 0 30) (end 0 0) (layer \"Edge.Cuts\"))", "(gr_line (start 0 30) (end 0 1) (layer \"Edge.Cuts\"))");+        let g = Geometry::from_text(&board(&open)).unwrap();+        let (closed, dangling) = g.outline_closed();+        assert!(!closed);+        assert_eq!(dangling, vec![(0.0, 0.0), (0.0, 1.0)]);+        let g = Geometry::from_text(&board("(gr_circle (center 5 5) (end 10 5) (layer \"Edge.Cuts\"))")).unwrap();+        assert!(g.outline_closed().0);+        assert!(!Geometry::from_text(&board("")).unwrap().outline_closed().0);+    }++    #[test]+    fn spacing_tracks_drills_and_rings() {+        let body = format!(+            "{OUTLINE}+            (segment (start 5 10) (end 30 10) (width 0.2) (layer \"F.Cu\") (net \"A\"))+            (segment (start 5 10.3) (end 30 10.3) (width 0.1) (layer \"F.Cu\") (net \"B\"))+            (segment (start 5 20) (end 30 20) (width 0.2) (layer \"B.Cu\") (net \"C\"))+            (via (at 20 25) (size 0.45) (drill 0.2) (layers \"F.Cu\" \"B.Cu\") (net \"A\"))"+        );+        let g = Geometry::from_text(&board(&body)).unwrap();+        assert_eq!(g.min_track(), Some(0.1));+        let (gap, a, b, _) = g.min_space().unwrap();+        assert!((gap - 0.15).abs() < 1e-9, "0.3 apart minus half widths 0.1 and 0.05: {gap}");+        assert!(a.contains("net") || a.contains("(A)") || b.contains("(A)"));+        let p = builtin_profile("jlcpcb").unwrap();+        let r = validate(&g, &p, &Options { assembly: false, max_items: 5 }, Err("kicad-cli not installed"));+        let checks: Vec<&str> = r["findings"].as_array().unwrap().iter().map(|f| f["check"].as_str().unwrap()).collect();+        assert!(checks.contains(&"min_track"), "{checks:?}");+        assert!(!checks.contains(&"min_space"), "0.15 >= 0.127");+        assert!(checks.contains(&"min_drill"), "0.2 < 0.3");+        assert!(!checks.contains(&"annular_ring"), "ring 0.125 >= 0.075");+        assert!(checks.contains(&"drc"));+        assert_eq!(r["pass"], false);+        let mut strict = p.clone();+        strict.min_space = 0.2;+        strict.min_annular = 0.13;+        let r = validate(&g, &strict, &Options { assembly: false, max_items: 5 }, Err("x"));+        let checks: Vec<&str> = r["findings"].as_array().unwrap().iter().map(|f| f["check"].as_str().unwrap()).collect();+        assert!(checks.contains(&"min_space") && checks.contains(&"annular_ring"), "{checks:?}");+    }++    #[test]+    fn edge_silk_via_in_pad_acid_trap_models_fiducials() {+        let body = format!(+            "{OUTLINE}+            (footprint \"R:R_0603\" (layer \"F.Cu\") (at 10 15 90)+              (property \"Reference\" \"R1\") (attr smd)+              (fp_line (start -1 0) (end 1 0) (stroke (width 0.12) (type solid)) (layer \"F.SilkS\"))+              (pad \"1\" smd rect (at -0.8 0 90) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\") (net \"A\"))+              (pad \"2\" smd rect (at 0.8 0 90) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\") (net \"B\")))+            (via (at 10 15.8) (size 0.4) (drill 0.2) (layers \"F.Cu\" \"B.Cu\") (net \"A\"))+            (segment (start 0.1 5) (end 10 5) (width 0.2) (layer \"F.Cu\") (net \"E\"))+            (segment (start 20 20) (end 30 20) (width 0.2) (layer \"F.Cu\") (net \"T\"))+            (segment (start 20 20) (end 25 22) (width 0.2) (layer \"F.Cu\") (net \"T\"))"+        );+        let g = Geometry::from_text(&board(&body)).unwrap();+        // rotated 90: pad 1 local (-0.8, 0) lands at (10, 15.8)+        assert!(g.pads.iter().any(|p| dist(p.a, (10.0, 15.8)) < 0.1 || dist(p.b, (10.0, 15.8)) < 0.1 || dist(((p.a.0 + p.b.0) / 2.0, (p.a.1 + p.b.1) / 2.0), (10.0, 15.8)) < 1e-6));+        assert_eq!(g.vias_in_pads().len(), 1);+        assert_eq!(g.silk_on_pads().len(), 2, "the silk line crosses both pads");+        let traps = g.acid_traps();+        assert_eq!(traps.len(), 1);+        assert!((traps[0].0 - 21.8).abs() < 0.2, "{traps:?}");+        let near = g.edge_gaps(0.3);+        assert!(near.iter().any(|(gap, who, _)| who.contains("(E)") && *gap < 0.1));+        let r = validate(&g, &builtin_profile("generic").unwrap(), &Options { assembly: true, max_items: 3 }, Err("x"));+        let checks: Vec<&str> = r["findings"].as_array().unwrap().iter().map(|f| f["check"].as_str().unwrap()).collect();+        for c in ["edge_clearance", "silk_over_pads", "via_in_pad", "acid_traps", "3d_models", "fiducials"] {+            assert!(checks.contains(&c), "{c} missing from {checks:?}");+        }+    }++    #[test]+    fn drc_report_is_folded_in() {+        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/rp2040-drc.json");+        let rep: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();+        let g = Geometry::from_text(&board(OUTLINE)).unwrap();+        let r = validate(&g, &builtin_profile("generic").unwrap(), &Options { assembly: false, max_items: 3 }, Ok(&rep));+        let summary = crate::cli::summarize_report(&rep);+        let has_drc = r["findings"].as_array().unwrap().iter().any(|f| f["check"] == "drc");+        assert_eq!(has_drc, summary["errors"].as_u64().unwrap() > 0);+        assert!(!r["findings"].as_array().unwrap().iter().any(|f| f["check"] == "drc" && f["severity"] == "warning"));+    }++    #[test]+    fn dru_minimums_override_the_profile() {+        let dru = "(version 1)\n# comment\n(rule \"track\" (constraint track_width (min 5mil)))\n(rule \"inner\" (layer inner) (constraint track_width (min 0.09mm)))\n(rule \"gap\" (constraint clearance (min 0.1mm)))\n(rule \"edge\" (constraint edge_clearance (min 0.2mm)))\n(rule \"hole\" (constraint hole_size (min 0.25mm)))";+        let m = dru_minimums(dru);+        assert!((m["track_width"] - 0.09).abs() < 1e-9);+        let mut p = builtin_profile("generic").unwrap();+        assert_eq!(apply_dru(&mut p, dru, "x.kicad_dru"), 4);+        assert!((p.min_space - 0.1).abs() < 1e-9 && (p.min_edge - 0.2).abs() < 1e-9 && (p.min_drill - 0.25).abs() < 1e-9);+        assert!((dru_length("0.005in").unwrap() - 0.127).abs() < 1e-9);+    }+}
rust/crates/kicad-core/src/fab.rsadded+768
@@ -0,0 +1,768 @@+//! Exports and manufacturing outputs that need no KiCad window: the kicad-cli argv for the+//! interchange formats (DXF, GenCAD, IPC-2581, ODB++, IPC-D-356), the BOM grouped from the+//! board's own footprints (generic and JLCPCB columns), the fab package README, a stored+//! (no compression) zip writer, and the board numbers a quote tool needs with a rough+//! JLCPCB-style cost table.+//!+//! The verb layer runs kicad-cli through `crate::cli::run`; everything here is pure so it+//! is unit-tested with canned inputs (kicad-cli is not needed to test it).++use std::collections::BTreeMap;+use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::model_check::{parse_sexpr, Sx};++// ---------------------------------------------------------------------------+// Interchange exports (kicad-cli pcb export <fmt>)+// ---------------------------------------------------------------------------++/// What one interchange export will run and where its output lands.+#[derive(Clone, Debug, PartialEq)]+pub struct ExportPlan {+    /// kicad-cli subcommand: dxf, gencad, ipc2581, odb, ipcd356.+    pub format: String,+    /// Full argv after the kicad-cli executable.+    pub argv: Vec<String>,+    pub out_dir: PathBuf,+    /// The single output file, or None for a per-layer directory export (DXF).+    pub out_file: Option<PathBuf>,+    /// File extension the export writes (".dxf", ".cad", ...), for the after-run listing.+    pub ext: String,+}++fn s(p: &Path) -> String {+    p.to_string_lossy().replace('\\', "/")+}++fn arg_s<'a>(args: &'a Value, k: &str) -> Option<&'a str> {+    args.get(k).and_then(Value::as_str).map(str::trim).filter(|v| !v.is_empty())+}++fn arg_b(args: &Value, k: &str) -> bool {+    args.get(k).and_then(Value::as_bool).unwrap_or(false)+}++/// `layers` as an array of names or one comma-separated string.+pub fn layer_list(v: Option<&Value>) -> Vec<String> {+    match v {+        Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),+        Some(Value::String(t)) => t.split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(),+        _ => Vec::new(),+    }+}++fn bad(code: &str, msg: impl Into<String>, hint: impl Into<String>) -> Value {+    json!({"success": false, "errorCode": code, "error": msg.into(), "_hint": hint.into()})+}++/// Build the kicad-cli argv for `format` on `board`. Errors are ready-to-return replies.+/// Options follow kicad-cli 9/10 (`kicad-cli pcb export <fmt> --help`).+pub fn plan_interchange(format: &str, board: &Path, args: &Value) -> Result<ExportPlan, Value> {+    let stem = board.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| "board".into());+    let out_dir = arg_s(args, "outputDir").map(PathBuf::from).unwrap_or_else(|| board.parent().unwrap_or(Path::new(".")).join(format!("{format}_output")));+    let units = arg_s(args, "units").unwrap_or("mm");+    if !["mm", "in"].contains(&units) {+        return Err(bad("bad_arg", "units must be mm or in", "Pass units:\"mm\" (default) or units:\"in\"."));+    }+    let mut argv: Vec<String> = vec!["pcb".into(), "export".into(), format.into()];+    let mut out_file: Option<PathBuf> = None;+    let ext: &str;+    match format {+        "dxf" => {+            let layers = layer_list(args.get("layers"));+            if layers.is_empty() {+                return Err(bad("missing_arg", "layers is required for DXF", "Pass layers, e.g. [\"Edge.Cuts\",\"F.Cu\"]: kicad-cli writes one .dxf per layer (named <board>-<layer>.dxf)."));+            }+            ext = ".dxf";+            argv.push("--output".into());+            argv.push(format!("{}/", s(&out_dir).trim_end_matches('/')));+            argv.push("--layers".into());+            argv.push(layers.join(","));+            argv.push("--mode-multi".into());+            argv.push("--output-units".into());+            argv.push(units.into());+            if arg_b(args, "useContours") {+                argv.push("--use-contours".into());+            }+            if arg_b(args, "includeBorderTitle") {+                argv.push("--include-border-title".into());+            }+            if arg_b(args, "excludeRefdes") {+                argv.push("--exclude-refdes".into());+            }+            if arg_b(args, "excludeValue") {+                argv.push("--exclude-value".into());+            }+            if arg_b(args, "useDrillOrigin") {+                argv.push("--use-drill-origin".into());+            }+        }+        "gencad" => {+            ext = ".cad";+            let f = out_dir.join(format!("{stem}.cad"));+            argv.push("--output".into());+            argv.push(s(&f));+            for (k, flag) in [("flipBottomPads", "--flip-bottom-pads"), ("uniquePins", "--unique-pins"), ("uniqueFootprints", "--unique-footprints"), ("useDrillOrigin", "--use-drill-origin"), ("storeOriginCoord", "--store-origin-coord")] {+                if arg_b(args, k) {+                    argv.push(flag.into());+                }+            }+            out_file = Some(f);+        }+        "ipc2581" => {+            let compress = arg_b(args, "compress");+            ext = if compress { ".zip" } else { ".xml" };+            let f = out_dir.join(format!("{stem}-ipc2581{ext}"));+            argv.push("--output".into());+            argv.push(s(&f));+            argv.push("--units".into());+            argv.push(units.into());+            if let Some(p) = args.get("precision").and_then(Value::as_u64) {+                argv.push("--precision".into());+                argv.push(p.clamp(1, 9).to_string());+            }+            if let Some(v) = arg_s(args, "version") {+                let v = v.to_uppercase();+                if v != "B" && v != "C" {+                    return Err(bad("bad_arg", "version must be B or C", "IPC-2581 revision: \"C\" (KiCad's default) or \"B\"."));+                }+                argv.push("--version".into());+                argv.push(v);+            }+            if compress {+                argv.push("--compress".into());+            }+            for (k, flag) in [("bomColMfgPn", "--bom-col-mfg-pn"), ("bomColMfg", "--bom-col-mfg"), ("bomColDistPn", "--bom-col-dist-pn"), ("bomColDist", "--bom-col-dist"), ("bomColIntId", "--bom-col-int-id")] {+                if let Some(v) = arg_s(args, k) {+                    argv.push(flag.into());+                    argv.push(v.into());+                }+            }+            out_file = Some(f);+        }+        "odb" => {+            let compression = arg_s(args, "compression").unwrap_or("zip");+            if !["zip", "tgz", "none"].contains(&compression) {+                return Err(bad("bad_arg", "compression must be zip, tgz or none", "ODB++ ships as a zip by default."));+            }+            ext = match compression { "zip" => ".zip", "tgz" => ".tgz", _ => "" };+            let f = if compression == "none" { out_dir.join(format!("{stem}-odb")) } else { out_dir.join(format!("{stem}-odb{ext}")) };+            argv.push("--output".into());+            argv.push(s(&f));+            argv.push("--units".into());+            argv.push(units.into());+            argv.push("--compression".into());+            argv.push(compression.into());+            if let Some(p) = args.get("precision").and_then(Value::as_u64) {+                argv.push("--precision".into());+                argv.push(p.clamp(1, 9).to_string());+            }+            out_file = Some(f);+        }+        "ipcd356" => {+            ext = ".d356";+            let f = out_dir.join(format!("{stem}.d356"));+            argv.push("--output".into());+            argv.push(s(&f));+            out_file = Some(f);+        }+        other => return Err(bad("bad_arg", format!("unknown interchange format {other}"), "One of dxf, gencad, ipc2581, odb, ipcd356.")),+    }+    argv.push(s(board));+    Ok(ExportPlan { format: format.into(), argv, out_dir, out_file, ext: ext.into() })+}++/// The reply for a finished export: `written` is what the caller found on disk afterwards+/// (name, bytes). A zero exit with nothing written is reported as a failure, not success.+pub fn export_reply(plan: &ExportPlan, exit_code: i32, timed_out: bool, stderr: &str, written: &[(String, u64)], label: &str) -> Value {+    if exit_code != 0 || written.is_empty() {+        let code = if timed_out { "cli_timeout" } else if exit_code == 0 { "no_output" } else { "cli_failed" };+        let why = if stderr.trim().is_empty() { "no stderr".to_string() } else { stderr.trim().to_string() };+        let mut hint = String::from("kicad-cli's stderr names the reason (file version, bad layer name, unknown option on an older kicad-cli).");+        if stderr.contains("nrecognized") || stderr.contains("Unknown") {+            hint = format!("This kicad-cli does not know an option the bridge passed (KiCad 9 or newer is needed for {}). kicad_list_versions shows the version.", plan.format);+        }+        return json!({"success": false, "errorCode": code, "error": format!("{label} export failed (exit {exit_code}): {why}"), "_hint": hint, "argv": plan.argv});+    }+    let files: Vec<Value> = written.iter().map(|(n, b)| json!({"name": n, "bytes": b})).collect();+    let bytes: u64 = written.iter().map(|(_, b)| b).sum();+    json!({+        "success": true,+        "output": format!("{label} exported: {} file(s), {bytes} bytes in {}", written.len(), s(&plan.out_dir)),+        "data": {+            "output_path": plan.out_file.as_ref().map(|f| s(f)).unwrap_or_else(|| s(&plan.out_dir)),+            "outputDir": s(&plan.out_dir),+            "files": files,+            "bytes": bytes,+        },+        "format": plan.format,+        "_hint": "Pull the files with ab pull_file. Run kicad_run_drc first: an interchange file carries the board's errors with it.",+    })+}++/// Files in `dir` (one level) with `ext` (empty: any) modified at or after `since`.+pub fn files_written(dir: &Path, ext: &str, since: std::time::SystemTime) -> Vec<(String, u64)> {+    let mut out: Vec<(String, u64)> = std::fs::read_dir(dir)+        .map(|rd| {+            rd.flatten()+                .filter_map(|e| {+                    let m = e.metadata().ok()?;+                    let name = e.file_name().to_string_lossy().to_string();+                    let fresh = m.modified().map(|t| t >= since).unwrap_or(false);+                    let ext_ok = ext.is_empty() || name.to_lowercase().ends_with(&ext.to_lowercase());+                    (fresh && ext_ok).then_some((name, if m.is_file() { m.len() } else { dir_size(&e.path()) }))+                })+                .collect()+        })+        .unwrap_or_default();+    out.sort();+    out+}++fn dir_size(p: &Path) -> u64 {+    std::fs::read_dir(p).map(|rd| rd.flatten().map(|e| { let q = e.path(); if q.is_dir() { dir_size(&q) } else { e.metadata().map(|m| m.len()).unwrap_or(0) } }).sum()).unwrap_or(0)+}++// ---------------------------------------------------------------------------+// Footprints as the BOM and the quote see them+// ---------------------------------------------------------------------------++/// One footprint with the fields the BOM, CPL and quote need.+#[derive(Clone, Debug, Default)]+pub struct Part {+    pub reference: String,+    pub value: String,+    pub footprint: String,+    pub layer: String,+    pub props: BTreeMap<String, String>,+    pub attrs: Vec<String>,+    pub smd_pads: usize,+    pub tht_pads: usize,+    pub has_model: bool,+}++impl Part {+    pub fn dnp(&self) -> bool {+        self.attrs.iter().any(|a| a == "dnp")+    }+    pub fn in_bom(&self) -> bool {+        !self.attrs.iter().any(|a| a == "exclude_from_bom" || a == "board_only") && !self.reference.starts_with('#')+    }+    pub fn is_smd(&self) -> bool {+        self.attrs.iter().any(|a| a == "smd") || (self.tht_pads == 0 && self.smd_pads > 0 && !self.attrs.iter().any(|a| a == "through_hole"))+    }+    pub fn is_tht(&self) -> bool {+        self.attrs.iter().any(|a| a == "through_hole") || (self.tht_pads > 0 && !self.attrs.iter().any(|a| a == "smd"))+    }+    /// The first non-empty property among `names` (case-insensitive).+    pub fn field(&self, names: &[&str]) -> String {+        for n in names {+            if let Some((_, v)) = self.props.iter().find(|(k, v)| k.eq_ignore_ascii_case(n) && !v.trim().is_empty()) {+                return v.trim().to_string();+            }+        }+        String::new()+    }+    pub fn mpn(&self) -> String {+        self.field(MPN_FIELDS)+    }+    pub fn lcsc(&self) -> String {+        self.field(LCSC_FIELDS)+    }+    pub fn manufacturer(&self) -> String {+        self.field(&["Manufacturer", "MFR", "Mfr", "Manufacturer_Name"])+    }+}++pub const MPN_FIELDS: &[&str] = &["MPN", "Manufacturer Part Number", "Manufacturer_Part_Number", "MFR PN", "MFR_PN", "Mfr. Part #", "PartNumber", "Part Number"];+pub const LCSC_FIELDS: &[&str] = &["LCSC", "LCSC Part", "LCSC Part #", "LCSC_PN", "LCSC#", "JLCPCB Part", "JLCPCB Part #", "JLC", "JLCPCB"];++/// Every footprint of a board, from the board text.+pub fn parts_from_board(text: &str) -> Result<Vec<Part>, String> {+    let tree = parse_sexpr(text).ok_or("empty board file")?;+    if tree.tag() != Some("kicad_pcb") {+        return Err("not a kicad_pcb file".into());+    }+    let mut out = Vec::new();+    for fp in tree.find_all("footprint") {+        let mut props = BTreeMap::new();+        for p in fp.find_all("property") {+            if let (Some(k), Some(v)) = (p.atom_at(1), p.atom_at(2)) {+                props.insert(k.to_string(), v.to_string());+            }+        }+        let attrs: Vec<String> = fp.find("attr").and_then(Sx::as_list).map(|l| l.iter().skip(1).filter_map(Sx::as_atom).map(str::to_string).collect()).unwrap_or_default();+        let mut smd = 0;+        let mut tht = 0;+        for pad in fp.find_all("pad") {+            match pad.atom_at(2) {+                Some("smd") => smd += 1,+                Some("thru_hole") => tht += 1,+                _ => {}+            }+        }+        out.push(Part {+            reference: props.get("Reference").cloned().unwrap_or_default(),+            value: props.get("Value").cloned().unwrap_or_default(),+            footprint: fp.atom_at(1).unwrap_or("").to_string(),+            layer: fp.find("layer").and_then(Sx::value).unwrap_or("F.Cu").to_string(),+            props,+            attrs,+            smd_pads: smd,+            tht_pads: tht,+            has_model: fp.find("model").is_some(),+        });+    }+    Ok(out)+}++/// Natural order for references: R2 before R10.+pub fn ref_key(r: &str) -> (String, u64, String) {+    let alpha: String = r.chars().take_while(|c| !c.is_ascii_digit()).collect();+    let rest = &r[alpha.len()..];+    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();+    (alpha, digits.parse().unwrap_or(0), rest[digits.len()..].to_string())+}++/// One BOM line: the parts sharing value, footprint and MPN.+#[derive(Clone, Debug)]+pub struct BomLine {+    pub refs: Vec<String>,+    pub value: String,+    pub footprint: String,+    pub mpn: String,+    pub manufacturer: String,+    pub lcsc: String,+    pub dnp: bool,+}++/// Group the board's parts into BOM lines (value + footprint + MPN), DNP parts on their own+/// lines, parts excluded from the BOM (or board-only) left out. Lines sort by first reference.+pub fn group_bom(parts: &[Part]) -> Vec<BomLine> {+    let mut map: BTreeMap<(String, String, String, bool), BomLine> = BTreeMap::new();+    for p in parts.iter().filter(|p| p.in_bom()) {+        let key = (p.value.clone(), p.footprint.clone(), p.mpn(), p.dnp());+        let line = map.entry(key).or_insert_with(|| BomLine { refs: Vec::new(), value: p.value.clone(), footprint: p.footprint.clone(), mpn: p.mpn(), manufacturer: String::new(), lcsc: String::new(), dnp: p.dnp() });+        line.refs.push(p.reference.clone());+        if line.lcsc.is_empty() {+            line.lcsc = p.lcsc();+        }+        if line.manufacturer.is_empty() {+            line.manufacturer = p.manufacturer();+        }+    }+    let mut lines: Vec<BomLine> = map.into_values().collect();+    for l in &mut lines {+        l.refs.sort_by_key(|r| ref_key(r));+    }+    lines.sort_by(|a, b| ref_key(a.refs.first().map(String::as_str).unwrap_or("")).cmp(&ref_key(b.refs.first().map(String::as_str).unwrap_or(""))));+    lines+}++fn csv_field(v: &str) -> String {+    if v.contains(',') || v.contains('"') || v.contains('\n') {+        format!("\"{}\"", v.replace('"', "\"\""))+    } else {+        v.to_string()+    }+}++/// The footprint name without its library nickname (JLCPCB wants the package, not `Lib:`).+pub fn fp_name(lib_id: &str) -> &str {+    lib_id.rsplit(':').next().unwrap_or(lib_id)+}++/// BOM CSV text. `fab` "jlcpcb" writes JLCPCB's assembly columns (Comment, Designator,+/// Footprint, LCSC Part #) and leaves DNP lines out; anything else the generic columns.+pub fn bom_csv(lines: &[BomLine], fab: &str) -> String {+    let mut out = String::new();+    if fab == "jlcpcb" {+        out.push_str("Comment,Designator,Footprint,LCSC Part #\n");+        for l in lines.iter().filter(|l| !l.dnp) {+            out.push_str(&format!("{},{},{},{}\n", csv_field(&l.value), csv_field(&l.refs.join(",")), csv_field(fp_name(&l.footprint)), csv_field(&l.lcsc)));+        }+    } else {+        out.push_str("Item,Qty,Reference(s),Value,Footprint,Manufacturer,MPN,LCSC,DNP\n");+        for (i, l) in lines.iter().enumerate() {+            out.push_str(&format!(+                "{},{},{},{},{},{},{},{},{}\n",+                i + 1,+                l.refs.len(),+                csv_field(&l.refs.join(",")),+                csv_field(&l.value),+                csv_field(&l.footprint),+                csv_field(&l.manufacturer),+                csv_field(&l.mpn),+                csv_field(&l.lcsc),+                if l.dnp { "DNP" } else { "" }+            ));+        }+    }+    out+}++/// What a BOM leaves for a human: lines with no LCSC part (JLCPCB) or no MPN (generic).+pub fn bom_gaps(lines: &[BomLine], fab: &str) -> Vec<String> {+    lines+        .iter()+        .filter(|l| !l.dnp)+        .filter(|l| if fab == "jlcpcb" { l.lcsc.is_empty() } else { l.mpn.is_empty() })+        .map(|l| format!("{} ({})", l.refs.join(","), l.value))+        .collect()+}++// ---------------------------------------------------------------------------+// Fab package README and zip+// ---------------------------------------------------------------------------++/// README.txt for a fab package: what each file is, the board's headline numbers, the fab.+pub fn readme(board_name: &str, fab: &str, files: &[(String, String)], numbers: &Value, notes: &[String]) -> String {+    let mut t = String::new();+    t.push_str(&format!("Fabrication package for {board_name}\n"));+    t.push_str(&format!("Prepared for: {}\n", if fab == "jlcpcb" { "JLCPCB (PCB + assembly columns)" } else { "any fab (generic columns)" }));+    t.push_str("Generated by the Adom KiCad Bridge (kicad_export_fab_package) with kicad-cli.\n\n");+    t.push_str("Board\n");+    let g = |k: &str| numbers.get(k).map(|v| v.to_string().trim_matches('"').to_string()).unwrap_or_else(|| "?".into());+    t.push_str(&format!("  Size: {} x {} mm\n  Copper layers: {}\n  Thickness: {} mm\n  Parts: {} SMD, {} THT ({} unique)\n\n", g("widthMm"), g("heightMm"), g("layers"), g("thicknessMm"), g("smdParts"), g("thtParts"), g("uniqueParts")));+    t.push_str("Files\n");+    for (name, what) in files {+        t.push_str(&format!("  {name}\n      {what}\n"));+    }+    if !notes.is_empty() {+        t.push_str("\nNotes\n");+        for n in notes {+            t.push_str(&format!("  - {n}\n"));+        }+    }+    t+}++fn crc32(data: &[u8]) -> u32 {+    static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new();+    let table = TABLE.get_or_init(|| {+        let mut t = [0u32; 256];+        for (i, slot) in t.iter_mut().enumerate() {+            let mut c = i as u32;+            for _ in 0..8 {+                c = if c & 1 != 0 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };+            }+            *slot = c;+        }+        t+    });+    let mut c = 0xFFFF_FFFFu32;+    for &b in data {+        c = table[((c ^ b as u32) & 0xff) as usize] ^ (c >> 8);+    }+    !c+}++/// A stored (method 0) zip of `(name in the archive, bytes)`. Gerbers are small and every+/// fab unpacks a stored zip, so no compressor is needed. Names use forward slashes.+pub fn zip_stored(files: &[(String, Vec<u8>)]) -> Result<Vec<u8>, String> {+    if files.len() > 0xFFFF {+        return Err("too many files for a zip without zip64".into());+    }+    let mut out = Vec::new();+    let mut cd = Vec::new();+    // 1980-01-01 00:00 in DOS time: deterministic archives.+    let (dos_time, dos_date) = (0u16, (0u16 << 9) | (1 << 5) | 1);+    for (name, data) in files {+        if data.len() > u32::MAX as usize || out.len() > u32::MAX as usize {+            return Err("file too large for a zip without zip64".into());+        }+        let name = name.replace('\\', "/");+        let off = out.len() as u32;+        let crc = crc32(data);+        let n = name.as_bytes();+        out.extend_from_slice(b"PK\x03\x04");+        out.extend_from_slice(&20u16.to_le_bytes()); // version needed+        out.extend_from_slice(&0x0800u16.to_le_bytes()); // UTF-8 names+        out.extend_from_slice(&0u16.to_le_bytes()); // stored+        out.extend_from_slice(&dos_time.to_le_bytes());+        out.extend_from_slice(&dos_date.to_le_bytes());+        out.extend_from_slice(&crc.to_le_bytes());+        out.extend_from_slice(&(data.len() as u32).to_le_bytes());+        out.extend_from_slice(&(data.len() as u32).to_le_bytes());+        out.extend_from_slice(&(n.len() as u16).to_le_bytes());+        out.extend_from_slice(&0u16.to_le_bytes());+        out.extend_from_slice(n);+        out.extend_from_slice(data);+        cd.extend_from_slice(b"PK\x01\x02");+        cd.extend_from_slice(&20u16.to_le_bytes()); // made by+        cd.extend_from_slice(&20u16.to_le_bytes()); // needed+        cd.extend_from_slice(&0x0800u16.to_le_bytes());+        cd.extend_from_slice(&0u16.to_le_bytes());+        cd.extend_from_slice(&dos_time.to_le_bytes());+        cd.extend_from_slice(&dos_date.to_le_bytes());+        cd.extend_from_slice(&crc.to_le_bytes());+        cd.extend_from_slice(&(data.len() as u32).to_le_bytes());+        cd.extend_from_slice(&(data.len() as u32).to_le_bytes());+        cd.extend_from_slice(&(n.len() as u16).to_le_bytes());+        cd.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); // extra, comment, disk, int attr, ext attr+        cd.extend_from_slice(&off.to_le_bytes());+        cd.extend_from_slice(n);+    }+    let cd_off = out.len() as u32;+    let cd_len = cd.len() as u32;+    out.extend_from_slice(&cd);+    out.extend_from_slice(b"PK\x05\x06");+    out.extend_from_slice(&[0, 0, 0, 0]);+    out.extend_from_slice(&(files.len() as u16).to_le_bytes());+    out.extend_from_slice(&(files.len() as u16).to_le_bytes());+    out.extend_from_slice(&cd_len.to_le_bytes());+    out.extend_from_slice(&cd_off.to_le_bytes());+    out.extend_from_slice(&[0, 0]);+    Ok(out)+}++/// Every file under `root` as (path relative to root with `/`, bytes), sorted.+pub fn collect_tree(root: &Path) -> Vec<(String, Vec<u8>)> {+    fn walk(base: &Path, dir: &Path, out: &mut Vec<(String, Vec<u8>)>) {+        let Ok(rd) = std::fs::read_dir(dir) else { return };+        for e in rd.flatten() {+            let p = e.path();+            if p.is_dir() {+                walk(base, &p, out);+            } else if let Ok(bytes) = std::fs::read(&p) {+                let rel = p.strip_prefix(base).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default();+                out.push((rel, bytes));+            }+        }+    }+    let mut out = Vec::new();+    walk(root, root, &mut out);+    out.sort_by(|a, b| a.0.cmp(&b.0));+    out+}++// ---------------------------------------------------------------------------+// Board numbers and a rough cost table+// ---------------------------------------------------------------------------++/// The numbers a PCB quote needs, from the board file and `crate::dfm`'s geometry.+pub fn board_numbers(text: &str) -> Result<Value, String> {+    let g = crate::dfm::Geometry::from_text(text)?;+    let parts = parts_from_board(text)?;+    let bom = group_bom(&parts);+    let placed: Vec<&Part> = parts.iter().filter(|p| p.in_bom() && !p.dnp()).collect();+    let smd = placed.iter().filter(|p| p.is_smd()).count();+    let tht = placed.iter().filter(|p| p.is_tht() && !p.is_smd()).count();+    let sides: std::collections::BTreeSet<&str> = placed.iter().map(|p| if p.layer.starts_with('B') { "bottom" } else { "top" }).collect();+    let smd_joints: usize = placed.iter().map(|p| p.smd_pads).sum();+    let tht_joints: usize = placed.iter().map(|p| p.tht_pads).sum();+    let (w, h) = g.outline_size().unwrap_or((0.0, 0.0));+    let mut drills: Vec<f64> = g.holes.iter().map(|h| r3(h.drill)).collect();+    drills.sort_by(|a, b| a.partial_cmp(b).unwrap());+    drills.dedup();+    let unique = bom.iter().filter(|l| !l.dnp).count();+    Ok(json!({+        "widthMm": r3(w), "heightMm": r3(h), "areaCm2": r3(w * h / 100.0),+        "layers": g.copper_layers.len(), "thicknessMm": g.thickness,+        "minTrackMm": g.min_track().map(r3), "minSpaceMm": g.min_space().map(|x| r3(x.0)),+        "minDrillMm": drills.first().copied(), "holes": g.holes.len(),+        "viaCount": g.holes.iter().filter(|h| h.via).count(), "drillSizes": drills,+        "smdParts": smd, "thtParts": tht, "uniqueParts": unique, "totalParts": placed.len(),+        "smdJoints": smd_joints, "thtJoints": tht_joints,+        "assemblySides": sides.into_iter().collect::<Vec<_>>(),+        "outlineClosed": g.outline_closed().0,+    }))+}++fn r3(v: f64) -> f64 {+    (v * 1000.0).round() / 1000.0+}++fn r2(v: f64) -> f64 {+    (v * 100.0).round() / 100.0+}++/// A rough JLCPCB-style price table from `board_numbers`. Every rate is an assumption and+/// the reply says so: this is a sanity check on order of magnitude, not a quote.+pub fn cost_table(n: &Value, quantities: &[u64], assembly: bool, basic_parts: Option<u64>) -> Value {+    let layers = n["layers"].as_u64().unwrap_or(2).max(1);+    let w = n["widthMm"].as_f64().unwrap_or(0.0);+    let h = n["heightMm"].as_f64().unwrap_or(0.0);+    let area_m2 = w * h / 1_000_000.0;+    // (setup USD, USD per m2 of boards) by copper count.+    let (setup, per_m2) = match layers {+        1 | 2 => (2.0, 30.0),+        3 | 4 => (8.0, 90.0),+        5 | 6 => (40.0, 200.0),+        _ => (80.0, 350.0),+    };+    let small_2l = layers <= 2 && w <= 100.0 && h <= 100.0;+    let thick = n["thicknessMm"].as_f64().unwrap_or(1.6);+    let thick_extra = if (thick - 1.6).abs() > 0.05 { 1.15 } else { 1.0 };+    let unique = n["uniqueParts"].as_u64().unwrap_or(0);+    let extended = basic_parts.map(|b| unique.saturating_sub(b)).unwrap_or(unique);+    let sides = n["assemblySides"].as_array().map(|a| a.len()).unwrap_or(1).max(1) as f64;+    let smd_j = n["smdJoints"].as_u64().unwrap_or(0) as f64;+    let tht_j = n["thtJoints"].as_u64().unwrap_or(0) as f64;+    let rows: Vec<Value> = quantities+        .iter()+        .map(|&q| {+            let qf = q as f64;+            let mut pcb = setup + per_m2 * area_m2 * qf;+            if small_2l && q <= 10 {+                pcb = if q <= 5 { 2.0 } else { 5.0 };+            }+            pcb = r2(pcb * thick_extra);+            let mut row = json!({"quantity": q, "pcbUsd": pcb});+            if assembly && n["totalParts"].as_u64().unwrap_or(0) > 0 {+                let setup_fee = 8.0 * sides;+                let stencil = 7.0 * sides;+                let joints = (smd_j * 0.0017 + tht_j * 0.0173) * qf;+                let loading = 3.0 * extended as f64;+                let asm = r2(setup_fee + stencil + joints + loading);+                row["assemblyUsd"] = json!(asm);+                row["totalUsdExcludingParts"] = json!(r2(pcb + asm));+            } else {+                row["totalUsdExcludingParts"] = json!(pcb);+            }+            row["perBoardUsd"] = json!(r2(row["totalUsdExcludingParts"].as_f64().unwrap_or(0.0) / qf.max(1.0)));+            row+        })+        .collect();+    let mut assumptions = vec![+        format!("PCB: {setup} USD setup plus {per_m2} USD per square metre of boards for {layers} copper layer(s), FR-4, HASL, green mask, standard tolerances."),+        "2-layer boards up to 100 x 100 mm: 2 USD for 5 and 5 USD for 10 (the promotional price).".to_string(),+        "Thickness other than 1.6 mm adds 15 percent.".to_string(),+        "Shipping, tax, customs, colour, finish (ENIG), impedance control and panelisation are not included.".to_string(),+    ];+    if assembly {+        assumptions.push(format!("Assembly: 8 USD setup and 7 USD stencil per assembled side ({sides} side(s)), 0.0017 USD per SMD joint, 0.0173 USD per THT joint, 3 USD loading fee per extended part ({extended} of {unique} unique parts counted as extended{}).", if basic_parts.is_some() { "" } else { "; pass basicParts to count the basic ones" }));+        assumptions.push("Component cost is NOT included: price the BOM with adom-parts-search or adom-instapcb-quote.".to_string());+    }+    json!({"currency": "USD", "rows": rows, "assumptions": assumptions, "rough": true})+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn dxf_requires_layers_and_writes_one_file_per_layer() {+        let b = Path::new("C:/p/board.kicad_pcb");+        let e = plan_interchange("dxf", b, &json!({})).unwrap_err();+        assert_eq!(e["errorCode"], "missing_arg");+        let p = plan_interchange("dxf", b, &json!({"layers": "Edge.Cuts, F.Cu", "units": "in", "useContours": true})).unwrap();+        assert_eq!(p.argv, vec!["pcb", "export", "dxf", "--output", "C:/p/dxf_output/", "--layers", "Edge.Cuts,F.Cu", "--mode-multi", "--output-units", "in", "--use-contours", "C:/p/board.kicad_pcb"]);+        assert!(p.out_file.is_none());+    }++    #[test]+    fn gencad_ipc2581_odb_ipcd356_argv() {+        let b = Path::new("/w/x.kicad_pcb");+        let g = plan_interchange("gencad", b, &json!({"outputDir": "/o", "uniquePins": true})).unwrap();+        assert_eq!(g.argv, vec!["pcb", "export", "gencad", "--output", "/o/x.cad", "--unique-pins", "/w/x.kicad_pcb"]);+        let i = plan_interchange("ipc2581", b, &json!({"outputDir": "/o", "compress": true, "version": "b", "bomColMfgPn": "MPN", "precision": 4})).unwrap();+        assert_eq!(i.argv, vec!["pcb", "export", "ipc2581", "--output", "/o/x-ipc2581.zip", "--units", "mm", "--precision", "4", "--version", "B", "--compress", "--bom-col-mfg-pn", "MPN", "/w/x.kicad_pcb"]);+        assert!(plan_interchange("ipc2581", b, &json!({"version": "D"})).is_err());+        let o = plan_interchange("odb", b, &json!({"outputDir": "/o"})).unwrap();+        assert_eq!(o.argv, vec!["pcb", "export", "odb", "--output", "/o/x-odb.zip", "--units", "mm", "--compression", "zip", "/w/x.kicad_pcb"]);+        assert!(plan_interchange("odb", b, &json!({"compression": "rar"})).is_err());+        let d = plan_interchange("ipcd356", b, &json!({"outputDir": "/o"})).unwrap();+        assert_eq!(d.argv, vec!["pcb", "export", "ipcd356", "--output", "/o/x.d356", "/w/x.kicad_pcb"]);+        assert!(plan_interchange("dxf", b, &json!({"layers": ["F.Cu"], "units": "cm"})).is_err());+    }++    #[test]+    fn export_reply_is_honest_about_empty_output_and_old_cli() {+        let p = plan_interchange("ipcd356", Path::new("/w/x.kicad_pcb"), &json!({"outputDir": "/o"})).unwrap();+        let ok = export_reply(&p, 0, false, "", &[("x.d356".into(), 1200)], "IPC-D-356");+        assert_eq!(ok["success"], true);+        assert_eq!(ok["data"]["files"][0]["bytes"], 1200);+        let none = export_reply(&p, 0, false, "", &[], "IPC-D-356");+        assert_eq!(none["errorCode"], "no_output");+        let old = export_reply(&p, 1, false, "Unknown argument: ipcd356", &[], "IPC-D-356");+        assert_eq!(old["errorCode"], "cli_failed");+        assert!(old["_hint"].as_str().unwrap().contains("KiCad 9 or newer"));+        let to = export_reply(&p, -2, true, "kicad-cli timed out", &[], "IPC-D-356");+        assert_eq!(to["errorCode"], "cli_timeout");+    }++    const BOARD: &str = r#"(kicad_pcb (version 20260206) (generator "pcbnew")+  (general (thickness 1.6))+  (layers (0 "F.Cu" signal) (2 "B.Cu" signal) (25 "Edge.Cuts" user))+  (footprint "Resistor_SMD:R_0603_1608Metric" (layer "F.Cu") (at 10 10)+    (property "Reference" "R10") (property "Value" "10k") (property "LCSC" "C25804") (property "MPN" "RC0603FR-0710KL")+    (attr smd) (pad "1" smd roundrect (at -0.8 0) (size 0.8 0.9) (layers "F.Cu")) (pad "2" smd roundrect (at 0.8 0) (size 0.8 0.9) (layers "F.Cu"))+    (model "${KICAD10_3DMODEL_DIR}/R.step"))+  (footprint "Resistor_SMD:R_0603_1608Metric" (layer "F.Cu") (at 14 10)+    (property "Reference" "R2") (property "Value" "10k") (property "LCSC" "C25804") (property "MPN" "RC0603FR-0710KL")+    (attr smd) (pad "1" smd roundrect (at -0.8 0) (size 0.8 0.9) (layers "F.Cu")) (pad "2" smd roundrect (at 0.8 0) (size 0.8 0.9) (layers "F.Cu")))+  (footprint "Resistor_SMD:R_0603_1608Metric" (layer "B.Cu") (at 18 10)+    (property "Reference" "R3") (property "Value" "1k")+    (attr smd dnp) (pad "1" smd roundrect (at -0.8 0) (size 0.8 0.9) (layers "B.Cu")) (pad "2" smd roundrect (at 0.8 0) (size 0.8 0.9) (layers "B.Cu")))+  (footprint "Connector:Pin_1x02" (layer "F.Cu") (at 30 10)+    (property "Reference" "J1") (property "Value" "Conn, 2 pin")+    (attr through_hole) (pad "1" thru_hole rect (at 0 0) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask")) (pad "2" thru_hole oval (at 2.54 0) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask")))+  (footprint "Logo:Adom" (layer "F.Cu") (at 40 10) (property "Reference" "G1") (property "Value" "LOGO") (attr board_only exclude_from_pos_files exclude_from_bom))+  (gr_rect (start 0 0) (end 50 20) (layer "Edge.Cuts"))+  (segment (start 10.8 10) (end 13.2 10) (width 0.25) (layer "F.Cu") (net "N1"))+  (via (at 20 15) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "N1"))+)"#;++    #[test]+    fn bom_groups_by_value_footprint_and_mpn_in_natural_order() {+        let parts = parts_from_board(BOARD).unwrap();+        assert_eq!(parts.len(), 5);+        let lines = group_bom(&parts);+        assert_eq!(lines.len(), 3, "R2+R10, R3 (dnp), J1; G1 is board-only");+        assert_eq!(lines[0].refs, vec!["J1"]);+        assert_eq!(lines[1].refs, vec!["R2", "R10"]);+        assert_eq!(lines[1].lcsc, "C25804");+        assert!(lines[2].dnp);+        let generic = bom_csv(&lines, "generic");+        assert!(generic.starts_with("Item,Qty,Reference(s),Value,Footprint,Manufacturer,MPN,LCSC,DNP\n"));+        assert!(generic.contains("1,1,J1,\"Conn, 2 pin\",Connector:Pin_1x02,,,,\n"), "{generic}");+        assert!(generic.contains("2,2,\"R2,R10\",10k,Resistor_SMD:R_0603_1608Metric,,RC0603FR-0710KL,C25804,\n"), "{generic}");+        assert!(generic.contains(",DNP\n"));+        let jlc = bom_csv(&lines, "jlcpcb");+        assert_eq!(jlc, "Comment,Designator,Footprint,LCSC Part #\n\"Conn, 2 pin\",J1,Pin_1x02,\n10k,\"R2,R10\",R_0603_1608Metric,C25804\n");+        assert_eq!(bom_gaps(&lines, "jlcpcb"), vec!["J1 (Conn, 2 pin)"]);+    }++    #[test]+    fn stored_zip_reads_back_with_the_bridge_reader() {+        let files = vec![("gerbers/x-F_Cu.gbr".to_string(), b"G04 test*\n".to_vec()), ("README.txt".to_string(), b"hello".to_vec())];+        let z = zip_stored(&files).unwrap();+        let zf = crate::pcm::ZipFile::parse(&z).unwrap();+        let names: Vec<&str> = zf.entries.iter().map(|e| e.name.as_str()).collect();+        assert_eq!(names, vec!["gerbers/x-F_Cu.gbr", "README.txt"]);+        assert_eq!(crc32(b"123456789"), 0xCBF4_3926, "standard CRC-32 check value");+    }++    #[test]+    fn readme_lists_every_file() {+        let n = json!({"widthMm": 50.0, "heightMm": 20.0, "layers": 2, "thicknessMm": 1.6, "smdParts": 2, "thtParts": 1, "uniqueParts": 2});+        let t = readme("board", "jlcpcb", &[("gerbers/".into(), "Gerber X2 and drill".into()), ("bom/board_BOM_JLCPCB.csv".into(), "BOM".into())], &n, &["2 BOM lines have no LCSC part".into()]);+        assert!(t.contains("Size: 50.0 x 20.0 mm"));+        assert!(t.contains("bom/board_BOM_JLCPCB.csv"));+        assert!(t.contains("- 2 BOM lines have no LCSC part"));+        assert!(!t.contains('\u{2014}'), "no em-dashes");+    }++    #[test]+    fn numbers_and_cost_table() {+        let n = board_numbers(BOARD).unwrap();+        assert_eq!(n["widthMm"], 50.0);+        assert_eq!(n["heightMm"], 20.0);+        assert_eq!(n["layers"], 2);+        assert_eq!(n["smdParts"], 2, "R3 is DNP, G1 board only");+        assert_eq!(n["thtParts"], 1);+        assert_eq!(n["uniqueParts"], 2);+        assert_eq!(n["holes"], 3, "two THT pads and one via");+        assert_eq!(n["minDrillMm"], 0.3);+        assert_eq!(n["minTrackMm"], 0.25);+        assert_eq!(n["outlineClosed"], true);+        let t = cost_table(&n, &[5, 100], true, Some(1));+        assert_eq!(t["rows"][0]["pcbUsd"], 2.0);+        assert!(t["rows"][1]["pcbUsd"].as_f64().unwrap() > 2.0);+        assert!(t["rows"][0]["assemblyUsd"].as_f64().unwrap() >= 18.0, "setup + stencil + one extended part");+        assert!(t["assumptions"].as_array().unwrap().iter().any(|a| a.as_str().unwrap().contains("NOT included")));+    }+}
rust/crates/kicad-core/src/fp_text.rsadded+813
@@ -0,0 +1,813 @@+//! Footprint blocks as KiCad text: place a library footprint on the board, move or rotate+//! one, flip it to the other side, give it fresh UUIDs, set its fields, attributes, lock+//! state, graphics and 3D models. Every function takes one `(footprint ...)` block and+//! returns the edited block; untouched bytes stay exactly as KiCad wrote them.+//!+//! KiCad's file conventions this follows (KiCad 7 to 10):+//! - children positions (`at`, `start`, `end`, `mid`, `center`, `xy`) are footprint-local+//!   and unrotated; the angle in a pad's or text's `(at x y a)` is ABSOLUTE (the footprint's+//!   orientation plus the item's own), so rotating a footprint adds the delta to it;+//! - zones inside a footprint carry absolute board coordinates;+//! - a flip (`FOOTPRINT::Flip`) keeps the anchor where it is, swaps every side-specific layer+//!   (F.x and B.x, inner copper In1 and In(n-2)), negates every local y, and sets the+//!   orientation to -a (top/bottom) or 180-a (left/right, KiCad's default); pad angles become+//!   -a or 180-a, text angles 180-a or -a, and side-specific text toggles its mirror.++use serde_json::{json, Value};++use crate::sxedit::{self, n, q, Edits, Node};++pub type FResult<T> = Result<T, String>;++/// The footprint's `(at x y [rot])`.+pub fn pose(fp: &Node) -> (f64, f64, f64) {+    let at = fp.kid("at");+    (+        at.and_then(|a| a.f64_at(1)).unwrap_or(0.0),+        at.and_then(|a| a.f64_at(2)).unwrap_or(0.0),+        at.and_then(|a| a.f64_at(3)).unwrap_or(0.0),+    )+}++pub fn norm_deg(a: f64) -> f64 {+    let mut r = a.rem_euclid(360.0);+    if (r - 360.0).abs() < 1e-9 {+        r = 0.0;+    }+    (r * 1e6).round() / 1e6+}++fn at_text(x: f64, y: f64, a: f64) -> String {+    let a = norm_deg(a);+    if a == 0.0 {+        format!("(at {} {})", n(x), n(y))+    } else {+        format!("(at {} {} {})", n(x), n(y), n(a))+    }+}++/// KiCad's `FlipLayer` for a board with `copper` copper layers.+pub fn flip_layer(name: &str, copper: usize) -> String {+    if let Some(rest) = name.strip_prefix("F.") {+        return format!("B.{rest}");+    }+    if let Some(rest) = name.strip_prefix("B.") {+        return format!("F.{rest}");+    }+    if let Some(k) = name.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|k| k.parse::<usize>().ok()) {+        let inner = copper.saturating_sub(2);+        if k >= 1 && k <= inner {+            return format!("In{}.Cu", inner + 1 - k);+        }+    }+    name.to_string()+}++fn is_side_specific(layer: &str) -> bool {+    layer.starts_with("F.") || layer.starts_with("B.")+}++/// The footprint's own layer, "F.Cu" or "B.Cu".+pub fn side(fp: &Node) -> String {+    fp.value_of("layer").unwrap_or("F.Cu").to_string()+}++/// Every `(uuid "...")` in the block replaced by a fresh one.+pub fn fresh_uuids(block: &str) -> FResult<String> {+    let root = sxedit::parse(block)?;+    let mut e = Edits::new();+    root.walk(&mut |nd| {+        if matches!(nd.tag(), Some("uuid") | Some("tstamp")) {+            if let Some(v) = nd.kids.get(1) {+                e.replace_node(v, q(&crate::schematic::new_uuid()));+            }+        }+    });+    e.apply(block)+}++fn rotate_local(p: (f64, f64), deg: f64) -> (f64, f64) {+    crate::placement::to_board((0.0, 0.0), deg, p)+}++/// Move and rotate a footprint block to `(x, y, rot)`: the anchor, every absolute child+/// angle by the rotation delta, and the absolute zone outlines with the anchor.+pub fn move_block(block: &str, x: f64, y: f64, rot: f64) -> FResult<String> {+    let fp = sxedit::parse(block)?;+    let (ox, oy, orot) = pose(&fp);+    let delta = rot - orot;+    let mut e = Edits::new();+    match fp.kid("at") {+        Some(at) => e.replace_node(at, at_text(x, y, rot)),+        None => {+            let after = fp.kid("layer").map(|l| l.end).unwrap_or(fp.kids[1].end);+            e.insert(after, format!(" {}", at_text(x, y, rot)));+        }+    }+    for k in fp.kids.iter().skip(1) {+        match k.tag() {+            Some("pad") | Some("property") | Some("fp_text") => {+                if delta.abs() > 1e-9 {+                    if let Some(at) = k.kid("at") {+                        let a = at.f64_at(3).unwrap_or(0.0) + delta;+                        e.replace_node(at, at_text(at.f64_at(1).unwrap_or(0.0), at.f64_at(2).unwrap_or(0.0), a));+                    }+                }+            }+            Some("zone") => {+                k.walk(&mut |nd| {+                    if nd.tag() == Some("xy") {+                        let p = (nd.f64_at(1).unwrap_or(0.0) - ox, nd.f64_at(2).unwrap_or(0.0) - oy);+                        let r = rotate_local(p, delta);+                        e.replace_node(nd, format!("(xy {} {})", n(x + r.0), n(y + r.1)));+                    }+                });+            }+            _ => {}+        }+    }+    e.apply(block)+}++/// Child kinds a client-side flip handles; anything else refuses the flip.+pub const FLIP_CHILDREN: &[&str] = &[+    "layer", "uuid", "tstamp", "at", "descr", "tags", "property", "path", "sheetname", "sheetfile", "attr", "units", "fp_text", "fp_line", "fp_rect", "fp_circle", "fp_arc",+    "fp_poly", "fp_curve", "pad", "model", "zone", "locked", "embedded_fonts", "embedded_files", "duplicate_pad_numbers_are_jumpers", "jumper_pad_groups", "net_tie_pad_groups",+    "solder_mask_margin", "solder_paste_margin", "solder_paste_margin_ratio", "solder_paste_ratio", "clearance", "zone_connect", "private_layers", "component_classes", "version",+    "generator", "generator_version", "placed", "autoplace_cost90", "autoplace_cost180", "thermal_width", "thermal_gap", "net_tie_pad_groups", "sheetname",+];++fn negate_points(nd: &Node, e: &mut Edits) {+    nd.walk(&mut |p| {+        if matches!(p.tag(), Some("xy") | Some("start") | Some("end") | Some("mid") | Some("center")) && p.kids.len() >= 3 {+            if let (Some(x), Some(y)) = (p.f64_at(1), p.f64_at(2)) {+                let tag = p.tag().unwrap_or("xy");+                e.replace_node(p, format!("({tag} {} {})", n(x), n(-y)));+            }+        }+    });+}++fn flip_layer_kid(nd: &Node, copper: usize, e: &mut Edits) {+    if let Some(l) = nd.kid("layer") {+        if let Some(v) = l.kids.get(1) {+            if let Some(name) = v.atom.as_deref() {+                e.replace_node(v, q(&flip_layer(name, copper)));+            }+        }+    }+}++fn toggle_mirror(text_item: &Node, e: &mut Edits) {+    let Some(effects) = text_item.kid("effects") else { return };+    match effects.kid("justify") {+        Some(j) => {+            let words: Vec<&str> = j.kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect();+            let mut kept: Vec<&str> = words.iter().copied().filter(|w| *w != "mirror").collect();+            if !words.contains(&"mirror") {+                kept.push("mirror");+            }+            if kept.is_empty() {+                e.replace(j.start, j.end, "");+            } else {+                e.replace_node(j, format!("(justify {})", kept.join(" ")));+            }+        }+        None => e.insert(effects.end - 1, " (justify mirror)"),+    }+}++/// Flip a footprint to the other side, about its own anchor, the way KiCad's+/// `FOOTPRINT::Flip` does. `left_right` is KiCad's default flip direction.+pub fn flip_block(block: &str, left_right: bool, copper: usize) -> FResult<String> {+    let fp = sxedit::parse(block)?;+    if fp.tag() != Some("footprint") {+        return Err("not a footprint block".into());+    }+    let (x, y, rot) = pose(&fp);+    let mut e = Edits::new();+    let new_rot = if left_right { 180.0 - rot } else { -rot };+    match fp.kid("at") {+        Some(at) => e.replace_node(at, at_text(x, y, new_rot)),+        None => return Err("footprint has no (at ...)".into()),+    }+    flip_layer_kid(&fp, copper, &mut e);+    for k in fp.kids.iter().skip(2) {+        let Some(tag) = k.tag() else { continue };+        match tag {+            "property" | "fp_text" => {+                if let Some(at) = k.kid("at") {+                    let a = at.f64_at(3).unwrap_or(0.0);+                    let na = if left_right { -a } else { 180.0 - a };+                    e.replace_node(at, at_text(at.f64_at(1).unwrap_or(0.0), -at.f64_at(2).unwrap_or(0.0), na));+                }+                let layer = k.value_of("layer").unwrap_or("");+                flip_layer_kid(k, copper, &mut e);+                if is_side_specific(layer) {+                    toggle_mirror(k, &mut e);+                }+            }+            "fp_line" | "fp_rect" | "fp_circle" | "fp_arc" | "fp_poly" | "fp_curve" => {+                negate_points(k, &mut e);+                flip_layer_kid(k, copper, &mut e);+            }+            "pad" => flip_pad(k, left_right, copper, &mut e)?,+            "zone" => {+                // absolute coordinates: mirror about the anchor; fills are dropped (KiCad refills)+                k.walk(&mut |nd| {+                    if nd.tag() == Some("filled_polygon") {+                        e.replace(nd.start, nd.end, "");+                    }+                });+                if let Some(poly) = k.kid("polygon") {+                    poly.walk(&mut |p| {+                        if p.tag() == Some("xy") {+                            let (px, py) = (p.f64_at(1).unwrap_or(0.0), p.f64_at(2).unwrap_or(0.0));+                            let (nx, ny) = if left_right { (2.0 * x - px, py) } else { (px, 2.0 * y - py) };+                            e.replace_node(p, format!("(xy {} {})", n(nx), n(ny)));+                        }+                    });+                }+                if let Some(ls) = k.kid("layers") {+                    let names: Vec<String> = ls.kids.iter().skip(1).filter_map(|a| a.atom.as_deref()).map(|l| q(&flip_layer(l, copper))).collect();+                    e.replace_node(ls, format!("(layers {})", names.join(" ")));+                }+                flip_layer_kid(k, copper, &mut e);+            }+            "model" => {+                let nonzero = |t: &str, i: usize| k.kid(t).and_then(|o| o.kid("xyz")).and_then(|v| v.f64_at(i)).map(|v| v.abs() > 1e-9).unwrap_or(false);+                if nonzero("offset", 2) || nonzero("rotate", 1) || nonzero("rotate", 2) {+                    return Err(format!("the 3D model {} has a y offset or an x/y rotation that a flip would have to mirror; the bridge does not reproduce KiCad's model transform", k.atom(1).unwrap_or("")));+                }+            }+            t if FLIP_CHILDREN.contains(&t) => {}+            other => return Err(format!("footprint carries a ({other} ...) child the bridge cannot flip")),+        }+    }+    e.apply(block)+}++fn flip_pad(pad: &Node, left_right: bool, copper: usize, e: &mut Edits) -> FResult<()> {+    if let Some(at) = pad.kid("at") {+        let a = at.f64_at(3).unwrap_or(0.0);+        let na = if left_right { 180.0 - a } else { -a };+        e.replace_node(at, at_text(at.f64_at(1).unwrap_or(0.0), -at.f64_at(2).unwrap_or(0.0), na));+    }+    if let Some(ls) = pad.kid("layers") {+        let names: Vec<String> = ls.kids.iter().skip(1).filter_map(|a| a.atom.as_deref()).map(|l| q(&flip_layer(l, copper))).collect();+        e.replace_node(ls, format!("(layers {})", names.join(" ")));+    }+    // pad-local geometry: drill offset, trapezoid delta, custom primitives+    if let Some(drill) = pad.kid("drill") {+        if let Some(off) = drill.kid("offset") {+            if let (Some(ox), Some(oy)) = (off.f64_at(1), off.f64_at(2)) {+                e.replace_node(off, format!("(offset {} {})", n(ox), n(-oy)));+            }+        }+    }+    if let Some(d) = pad.kid("rect_delta") {+        if let (Some(dx), Some(dy)) = (d.f64_at(1), d.f64_at(2)) {+            e.replace_node(d, format!("(rect_delta {} {})", n(dx), n(-dy)));+        }+    }+    if let Some(prims) = pad.kid("primitives") {+        negate_points(prims, e);+    }+    if let Some(ch) = pad.kid("chamfer") {+        let words: Vec<String> = ch+            .kids+            .iter()+            .skip(1)+            .filter_map(|k| k.atom.as_deref())+            .map(|w| match w {+                "top_left" => "bottom_left",+                "bottom_left" => "top_left",+                "top_right" => "bottom_right",+                "bottom_right" => "top_right",+                o => o,+            }.to_string())+            .collect();+        e.replace_node(ch, format!("(chamfer {})", words.join(" ")));+    }+    if pad.kid("padstack").is_some() {+        return Err(format!("pad {} has a per-layer padstack the bridge does not flip", pad.atom(1).unwrap_or("")));+    }+    Ok(())+}++/// Set (or add) a property's value. A new property is hidden on the Fab layer of the+/// footprint's side.+pub fn set_property(block: &str, name: &str, value: &str) -> FResult<String> {+    let fp = sxedit::parse(block)?;+    let mut e = Edits::new();+    match fp.kids_tagged("property").find(|p| p.atom(1) == Some(name)) {+        Some(p) => match p.kids.get(2) {+            Some(v) => e.replace_node(v, q(value)),+            None => e.insert(p.kids[1].end, format!(" {}", q(value))),+        },+        None => {+            let fab = if side(&fp) == "B.Cu" { "B.Fab" } else { "F.Fab" };+            let anchor = fp.kids_tagged("property").last().map(|p| p.end).or_else(|| fp.kid("at").map(|a| a.end)).unwrap_or(fp.kids[1].end);+            let rot = pose(&fp).2;+            e.insert(+                anchor,+                format!(+                    "\n\t\t(property {} {}\n\t\t\t{}\n\t\t\t(layer {})\n\t\t\t(hide yes)\n\t\t\t(uuid {})\n\t\t\t(effects (font (size 1 1) (thickness 0.15)))\n\t\t)",+                    q(name),+                    q(value),+                    at_text(0.0, 0.0, rot),+                    q(fab),+                    q(&crate::schematic::new_uuid())+                ),+            );+        }+    }+    e.apply(block)+}++/// Remove a user property (the four mandatory fields are refused).+pub fn remove_property(block: &str, name: &str) -> FResult<String> {+    if ["Reference", "Value", "Footprint", "Datasheet", "Description"].contains(&name) {+        return Err(format!("{name} is a mandatory field and cannot be removed"));+    }+    let fp = sxedit::parse(block)?;+    let Some(p) = fp.kids_tagged("property").find(|p| p.atom(1) == Some(name)) else {+        return Err(format!("no property named {name}"));+    };+    let mut e = Edits::new();+    e.remove_node(block, p);+    e.apply(block)+}++/// Every property (name, value).+pub fn properties(fp: &Node) -> Vec<(String, String)> {+    fp.kids_tagged("property").filter_map(|p| Some((p.atom(1)?.to_string(), p.atom(2).unwrap_or("").to_string()))).collect()+}++/// The `(attr ...)` words that are flags (everything but the mounting type).+pub const ATTR_FLAGS: &[(&str, &str)] = &[+    ("excludeFromPosFiles", "exclude_from_pos_files"),+    ("excludeFromBom", "exclude_from_bom"),+    ("dnp", "dnp"),+    ("boardOnly", "board_only"),+    ("allowMissingCourtyard", "allow_missing_courtyard"),+    ("allowSoldermaskBridges", "allow_soldermask_bridges"),+];++/// The footprint's attributes as JSON: type (smd, through_hole, unspecified) and each flag.+pub fn attrs(fp: &Node) -> Value {+    let words: Vec<&str> = fp.kid("attr").map(|a| a.kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect()).unwrap_or_default();+    let mut v = json!({"type": words.iter().find(|w| **w == "smd" || **w == "through_hole").copied().unwrap_or("unspecified")});+    for (k, w) in ATTR_FLAGS {+        v[*k] = json!(words.contains(w));+    }+    v+}++/// Set attribute flags (`changes` maps the JSON names of `ATTR_FLAGS` to bools; `type` too).+pub fn set_attrs(block: &str, changes: &serde_json::Map<String, Value>) -> FResult<String> {+    let fp = sxedit::parse(block)?;+    let cur = attrs(&fp);+    let ty = match changes.get("type") {+        Some(Value::String(t)) if matches!(t.as_str(), "smd" | "through_hole" | "unspecified") => t.clone(),+        Some(_) => return Err("type must be smd, through_hole or unspecified".into()),+        None => cur["type"].as_str().unwrap_or("unspecified").to_string(),+    };+    let mut words: Vec<&str> = Vec::new();+    if ty != "unspecified" {+        words.push(if ty == "smd" { "smd" } else { "through_hole" });+    }+    for (k, w) in ATTR_FLAGS {+        let on = match changes.get(*k) {+            Some(Value::Bool(b)) => *b,+            Some(_) => return Err(format!("{k} must be true or false")),+            None => cur[*k].as_bool().unwrap_or(false),+        };+        if on {+            words.push(w);+        }+    }+    let text = if words.is_empty() { String::new() } else { format!("(attr {})", words.join(" ")) };+    let mut e = Edits::new();+    match fp.kid("attr") {+        Some(a) if text.is_empty() => e.remove_node(block, a),+        Some(a) => e.replace_node(a, text),+        None if text.is_empty() => {}+        None => {+            let anchor = fp.kid("sheetfile").or_else(|| fp.kids_tagged("property").last()).or_else(|| fp.kid("at")).map(|n| n.end).unwrap_or(fp.kids[1].end);+            e.insert(anchor, format!("\n\t\t{text}"));+        }+    }+    e.apply(block)+}++/// Lock or unlock: `(locked yes)` after the layer, as KiCad 9 and 10 write it (the bare+/// `locked` word of older files is removed either way).+pub fn set_locked(block: &str, locked: bool) -> FResult<String> {+    let fp = sxedit::parse(block)?;+    let mut e = Edits::new();+    for k in fp.kids.iter().skip(2) {+        if k.tag() == Some("locked") || (!k.quoted && k.atom.as_deref() == Some("locked")) {+            e.replace(k.start, k.end, "");+        }+    }+    if locked {+        let after = fp.kid("layer").map(|l| l.end).unwrap_or(fp.kids[1].end);+        e.insert(after, " (locked yes)");+    }+    e.apply(block)+}++pub fn is_locked(fp: &Node) -> bool {+    fp.kid("locked").map(|l| l.atom(1) != Some("no")).unwrap_or(false) || fp.has_word("locked")+}++/// Pad nets removed (a duplicate is a new part; it must not short its source's nets).+pub fn strip_nets(block: &str) -> FResult<String> {+    let fp = sxedit::parse(block)?;+    let mut e = Edits::new();+    for pad in fp.kids_tagged("pad") {+        if let Some(net) = pad.kid("net") {+            e.replace(net.start, net.end, "");+        }+    }+    e.apply(block)+}++/// The symbol link removed (`path`, `sheetname`, `sheetfile`): the copy is not in the schematic.+pub fn strip_symbol_link(block: &str) -> FResult<String> {+    let fp = sxedit::parse(block)?;+    let mut e = Edits::new();+    for t in ["path", "sheetname", "sheetfile"] {+        if let Some(k) = fp.kid(t) {+            e.remove_node(block, k);+        }+    }+    e.apply(block)+}++/// A library footprint (`.kicad_mod` text) as a board footprint at `(x, y, rot)` on `side`,+/// with the given reference (and value when given), fresh UUIDs, named `lib_id`.+pub fn place_library(lib_text: &str, lib_id: &str, x: f64, y: f64, rot: f64, back: bool, reference: &str, value: Option<&str>, copper: usize) -> FResult<String> {+    let root = sxedit::parse(lib_text)?;+    if !matches!(root.tag(), Some("footprint") | Some("module")) {+        return Err("the library file is not a (footprint ...)".into());+    }+    let mut e = Edits::new();+    e.replace_node(&root.kids[0], "footprint");+    match root.kids.get(1) {+        Some(name) if name.atom.is_some() => e.replace_node(name, q(lib_id)),+        _ => return Err("the library footprint has no name".into()),+    }+    for t in ["version", "generator", "generator_version", "at", "uuid", "tstamp", "locked", "placed"] {+        for k in root.kids_tagged(t) {+            e.remove_node(lib_text, k);+        }+    }+    let after = root.kid("layer").map(|l| l.end).unwrap_or(root.kids[1].end);+    if root.kid("layer").is_none() {+        e.insert(after, " (layer \"F.Cu\")");+    }+    e.insert(after, format!("\n\t\t(uuid {})\n\t\t(at 0 0)", q(&crate::schematic::new_uuid())));+    let text = e.apply(lib_text)?;+    let text = fresh_uuids(&text)?;+    let mut text = set_property(&text, "Reference", reference)?;+    if let Some(v) = value {+        text = set_property(&text, "Value", v)?;+    }+    if back {+        text = flip_block(&text, true, copper)?;+    }+    move_block(&text, x, y, rot)+}++/// The board's copper layer count from its `(layers ...)` table.+pub fn copper_count(root: &Node) -> usize {+    root.kid("layers").map(|ls| ls.kids.iter().skip(1).filter(|l| l.atom(1).map(|n| n.ends_with(".Cu")).unwrap_or(false)).count()).unwrap_or(2).max(2)+}++/// Graphics of a placed footprint: every fp_line/rect/circle/arc/poly/curve and fp_text,+/// with its layer, kind, local points, width and uuid.+pub fn graphics(fp: &Node) -> Vec<Value> {+    let mut out = Vec::new();+    for k in fp.kids.iter().skip(2) {+        let Some(tag) = k.tag() else { continue };+        let kind = match tag {+            "fp_line" => "line",+            "fp_rect" => "rect",+            "fp_circle" => "circle",+            "fp_arc" => "arc",+            "fp_poly" => "polygon",+            "fp_curve" => "bezier",+            "fp_text" => "text",+            _ => continue,+        };+        let pt = |t: &str| k.kid(t).and_then(|p| Some(json!([p.f64_at(1)?, p.f64_at(2)?])));+        let mut row = json!({"uuid": k.uuid(), "kind": kind, "layer": k.value_of("layer")});+        for t in ["start", "mid", "end", "center"] {+            if let Some(p) = pt(t) {+                row[t] = p;+            }+        }+        if let Some(pts) = k.kid("pts") {+            row["points"] = json!(pts.kids_tagged("xy").filter_map(|p| Some([p.f64_at(1)?, p.f64_at(2)?])).collect::<Vec<_>>());+        }+        if let Some(w) = k.kid("stroke").and_then(|s| s.kid("width")).and_then(|w| w.f64_at(1)).or_else(|| k.kid("width").and_then(|w| w.f64_at(1))) {+            row["width"] = json!(w);+        }+        if let Some(f) = k.kid("fill") {+            row["fill"] = json!(f.atom(1));+        }+        if tag == "fp_text" {+            row["text"] = json!(k.atom(2));+            row["textKind"] = json!(k.atom(1));+        }+        out.push(row);+    }+    out+}++/// Edit one footprint graphic by uuid: `layer`, `width`, `points` (polygon) or any of+/// `start`, `mid`, `end`, `center` as [x, y] in footprint-local mm, `fill`, or `delete:true`.+pub fn edit_graphic(block: &str, uuid: &str, change: &Value) -> FResult<String> {+    let fp = sxedit::parse(block)?;+    let Some(item) = fp.kids.iter().skip(2).find(|k| k.tag().map(|t| t.starts_with("fp_")).unwrap_or(false) && k.uuid() == Some(uuid)) else {+        return Err(format!("no footprint graphic with uuid {uuid}"));+    };+    let mut e = Edits::new();+    if change.get("delete") == Some(&json!(true)) {+        e.remove_node(block, item);+        return e.apply(block);+    }+    let pair = |v: &Value| -> FResult<(f64, f64)> {+        let a = v.as_array().filter(|a| a.len() == 2).ok_or("a point is [x, y] in mm")?;+        let (x, y) = (a[0].as_f64().ok_or("x must be a number")?, a[1].as_f64().ok_or("y must be a number")?);+        if !x.is_finite() || !y.is_finite() {+            return Err("points must be finite".into());+        }+        Ok((x, y))+    };+    let mut touched = false;+    for t in ["start", "mid", "end", "center"] {+        if let Some(v) = change.get(t) {+            let (x, y) = pair(v)?;+            match item.kid(t) {+                Some(p) => e.replace_node(p, format!("({t} {} {})", n(x), n(y))),+                None => return Err(format!("this {} has no {t} point", item.tag().unwrap_or(""))),+            }+            touched = true;+        }+    }+    if let Some(v) = change.get("points") {+        let pts = v.as_array().ok_or("points is a list of [x, y]")?;+        if pts.len() < 2 {+            return Err("points needs at least 2 vertices".into());+        }+        let Some(old) = item.kid("pts") else { return Err("only a polygon or bezier takes points".into()) };+        let mut s = String::from("(pts");+        for p in pts {+            let (x, y) = pair(p)?;+            s.push_str(&format!(" (xy {} {})", n(x), n(y)));+        }+        s.push(')');+        e.replace_node(old, s);+        touched = true;+    }+    if let Some(w) = change.get("width") {+        let w = w.as_f64().filter(|w| w.is_finite() && *w >= 0.0 && *w < 100.0).ok_or("width is mm, 0 to 100")?;+        match item.kid("stroke").and_then(|s| s.kid("width")) {+            Some(old) => e.replace_node(old, format!("(width {})", n(w))),+            None => match item.kid("width") {+                Some(old) => e.replace_node(old, format!("(width {})", n(w))),+                None => e.insert(item.end - 1, format!(" (stroke (width {}) (type solid))", n(w))),+            },+        }+        touched = true;+    }+    if let Some(l) = change.get("layer") {+        let l = l.as_str().ok_or("layer is a layer name")?;+        match item.kid("layer").and_then(|k| k.kids.get(1)) {+            Some(v) => e.replace_node(v, q(l)),+            None => return Err("this graphic has no layer".into()),+        }+        touched = true;+    }+    if let Some(f) = change.get("fill") {+        let f = match f {+            Value::Bool(true) => "yes",+            Value::Bool(false) => "no",+            Value::String(s) if s == "yes" || s == "no" || s == "solid" || s == "none" => if s == "solid" { "yes" } else if s == "none" { "no" } else { s.as_str() },+            _ => return Err("fill is true or false".into()),+        };+        match item.kid("fill") {+            Some(old) => e.replace_node(old, format!("(fill {f})")),+            None => e.insert(item.end - 1, format!(" (fill {f})")),+        }+        touched = true;+    }+    if !touched {+        return Err("nothing to change: pass layer, width, points, start/mid/end/center, fill or delete:true".into());+    }+    e.apply(block)+}++/// A footprint's 3D model entries.+pub fn models(fp: &Node) -> Vec<Value> {+    fp.kids_tagged("model")+        .enumerate()+        .map(|(i, m)| {+            let v3 = |t: &str, d: f64| -> Value {+                let x = m.kid(t).and_then(|o| o.kid("xyz"));+                json!([x.and_then(|v| v.f64_at(1)).unwrap_or(d), x.and_then(|v| v.f64_at(2)).unwrap_or(d), x.and_then(|v| v.f64_at(3)).unwrap_or(d)])+            };+            let hidden = m.has_word("hide") || m.kid("hide").map(|h| h.atom(1) != Some("no")).unwrap_or(false);+            json!({"index": i, "path": m.atom(1), "offset": v3("offset", 0.0), "scale": v3("scale", 1.0), "rotate": v3("rotate", 0.0), "visible": !hidden,+                   "opacity": m.kid("opacity").and_then(|o| o.f64_at(1))})+        })+        .collect()+}++fn xyz(v: Option<&Value>, d: [f64; 3], what: &str) -> FResult<[f64; 3]> {+    match v {+        None | Some(Value::Null) => Ok(d),+        Some(Value::Array(a)) if a.len() == 3 => {+            let mut o = [0.0; 3];+            for (i, x) in a.iter().enumerate() {+                o[i] = x.as_f64().filter(|f| f.is_finite()).ok_or(format!("{what} must be three finite numbers"))?;+            }+            Ok(o)+        }+        _ => Err(format!("{what} must be [x, y, z]")),+    }+}++fn model_text(path: &str, offset: [f64; 3], scale: [f64; 3], rotate: [f64; 3], hidden: bool) -> String {+    let v = |a: [f64; 3]| format!("(xyz {} {} {})", n(a[0]), n(a[1]), n(a[2]));+    format!("(model {}{}\n\t\t\t(offset\n\t\t\t\t{}\n\t\t\t)\n\t\t\t(scale\n\t\t\t\t{}\n\t\t\t)\n\t\t\t(rotate\n\t\t\t\t{}\n\t\t\t)\n\t\t)", q(path), if hidden { " (hide yes)" } else { "" }, v(offset), v(scale), v(rotate))+}++/// Add, replace or remove 3D model entries. `ops` is a list of+/// `{action: add|replace|remove, index?, path?, offset?, scale?, rotate?, visible?}`.+pub fn edit_models(block: &str, ops: &[Value]) -> FResult<String> {+    let mut text = block.to_string();+    for op in ops {+        let fp = sxedit::parse(&text)?;+        let existing: Vec<&Node> = fp.kids_tagged("model").collect();+        let action = op.get("action").and_then(Value::as_str).unwrap_or("");+        let index = || -> FResult<usize> {+            match op.get("index").and_then(Value::as_u64) {+                Some(i) if (i as usize) < existing.len() => Ok(i as usize),+                Some(i) => Err(format!("model index {i} out of range ({} models)", existing.len())),+                None => match op.get("path").and_then(Value::as_str) {+                    Some(p) => existing.iter().position(|m| m.atom(1) == Some(p)).ok_or(format!("no model with path {p}")),+                    None if existing.len() == 1 => Ok(0),+                    None => Err("name the model with index or path".into()),+                },+            }+        };+        let mut e = Edits::new();+        match action {+            "add" => {+                let path = op.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("add needs path")?;+                let t = model_text(path, xyz(op.get("offset"), [0.0; 3], "offset")?, xyz(op.get("scale"), [1.0; 3], "scale")?, xyz(op.get("rotate"), [0.0; 3], "rotate")?, op.get("visible") == Some(&json!(false)));+                e.insert(fp.end - 1, format!("\t{t}\n\t"));+            }+            "replace" => {+                let i = index()?;+                let old = models(&fp)[i].clone();+                let get = |k: &str, d: &Value| op.get(k).cloned().unwrap_or_else(|| d.clone());+                let path = op.get("newPath").or_else(|| if op.get("index").is_some() { op.get("path") } else { None }).and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| old["path"].as_str().unwrap_or("").to_string());+                let t = model_text(+                    &path,+                    xyz(Some(&get("offset", &old["offset"])), [0.0; 3], "offset")?,+                    xyz(Some(&get("scale", &old["scale"])), [1.0; 3], "scale")?,+                    xyz(Some(&get("rotate", &old["rotate"])), [0.0; 3], "rotate")?,+                    !get("visible", &old["visible"]).as_bool().unwrap_or(true),+                );+                e.replace_node(existing[i], t);+            }+            "remove" => {+                let i = index()?;+                e.remove_node(&text, existing[i]);+            }+            other => return Err(format!("action must be add, replace or remove, not '{other}'")),+        }+        text = e.apply(&text)?;+    }+    Ok(text)+}++#[cfg(test)]+mod tests {+    use super::*;++    const CAP: &str = "(footprint \"Capacitor_SMD:C_0805\"\n\t\t(layer \"F.Cu\")\n\t\t(uuid \"u-fp\")\n\t\t(at 10 20 90)\n\t\t(property \"Reference\" \"C7\" (at 0 -1.68 90) (layer \"F.SilkS\") (uuid \"u-ref\") (effects (font (size 1 1) (thickness 0.15))))\n\t\t(property \"Value\" \"1u\" (at 0 1.68 90) (layer \"F.Fab\") (uuid \"u-val\") (effects (font (size 1 1))))\n\t\t(attr smd)\n\t\t(fp_line (start -0.26 -0.73) (end 0.26 -0.73) (stroke (width 0.12) (type solid)) (layer \"F.SilkS\") (uuid \"u-l1\"))\n\t\t(fp_poly (pts (xy -1.7 -0.98) (xy 1.7 -0.98) (xy 1.7 0.98)) (stroke (width 0.05) (type solid)) (fill no) (layer \"F.CrtYd\") (uuid \"u-cy\"))\n\t\t(pad \"1\" smd roundrect (at -0.95 0.2 90) (size 1 1.45) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (chamfer top_left) (net \"+VBAT\") (uuid \"u-p1\"))\n\t\t(pad \"2\" thru_hole circle (at 0.95 0 90) (size 1 1) (drill 0.5 (offset 0 0.1)) (layers \"*.Cu\" \"*.Mask\") (net \"GND\") (uuid \"u-p2\"))\n\t\t(model \"x.step\" (offset (xyz 1 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 90)))\n\t)";++    #[test]+    fn flip_left_right_mirrors_like_kicad() {+        let out = flip_block(CAP, true, 4).unwrap();+        let fp = sxedit::parse(&out).unwrap();+        assert_eq!(fp.value_of("layer"), Some("B.Cu"));+        assert_eq!(pose(&fp), (10.0, 20.0, 90.0)); // 180 - 90+        let p1 = fp.kids_tagged("pad").next().unwrap();+        let at = p1.kid("at").unwrap();+        assert_eq!((at.f64_at(1), at.f64_at(2), at.f64_at(3)), (Some(-0.95), Some(-0.2), Some(90.0)));+        assert_eq!(p1.kid("layers").unwrap().kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect::<Vec<_>>(), vec!["B.Cu", "B.Mask", "B.Paste"]);+        assert_eq!(p1.kid("chamfer").unwrap().atom(1), Some("bottom_left"));+        let p2 = fp.kids_tagged("pad").nth(1).unwrap();+        assert_eq!(p2.kid("layers").unwrap().atom(1), Some("*.Cu"));+        assert_eq!(p2.kid("drill").unwrap().kid("offset").unwrap().f64_at(2), Some(-0.1));+        let r = fp.kids_tagged("property").next().unwrap();+        assert_eq!(r.value_of("layer"), Some("B.SilkS"));+        assert_eq!(r.kid("at").unwrap().f64_at(2), Some(1.68));+        assert_eq!(r.kid("at").unwrap().f64_at(3), Some(270.0)); // -90+        assert_eq!(r.kid("effects").unwrap().kid("justify").unwrap().atom(1), Some("mirror"));+        let cy = fp.kids_tagged("fp_poly").next().unwrap();+        assert_eq!(cy.value_of("layer"), Some("B.CrtYd"));+        assert_eq!(cy.kid("pts").unwrap().kids[1].f64_at(2), Some(0.98));+        // flipping twice is the identity on every coordinate and layer+        let back = flip_block(&out, true, 4).unwrap();+        let b = sxedit::parse(&back).unwrap();+        assert_eq!(pose(&b), (10.0, 20.0, 90.0));+        assert_eq!(b.value_of("layer"), Some("F.Cu"));+        assert!(b.kids_tagged("property").next().unwrap().kid("effects").unwrap().kid("justify").is_none());+        assert_eq!(flip_layer("In1.Cu", 6), "In4.Cu");+        assert_eq!(flip_layer("In2.Cu", 4), "In1.Cu");+    }++    #[test]+    fn a_model_the_flip_would_have_to_move_is_refused() {+        let bad = CAP.replace("(offset (xyz 1 0 0))", "(offset (xyz 1 0.5 0))");+        assert!(flip_block(&bad, true, 2).unwrap_err().contains("3D model"));+    }++    #[test]+    fn move_rotates_absolute_angles_and_fields_attrs_lock_edit_in_place() {+        let out = move_block(CAP, 50.0, 60.0, 180.0).unwrap();+        let fp = sxedit::parse(&out).unwrap();+        assert_eq!(pose(&fp), (50.0, 60.0, 180.0));+        assert_eq!(fp.kids_tagged("pad").next().unwrap().kid("at").unwrap().f64_at(3), Some(180.0));+        assert_eq!(fp.kids_tagged("property").next().unwrap().kid("at").unwrap().f64_at(3), Some(180.0));+        let v = set_property(&out, "Value", "2u2").unwrap();+        let v = set_property(&v, "LCSC", "C1234").unwrap();+        let fp = sxedit::parse(&v).unwrap();+        assert!(properties(&fp).contains(&("Value".into(), "2u2".into())));+        assert!(properties(&fp).contains(&("LCSC".into(), "C1234".into())));+        let mut ch = serde_json::Map::new();+        ch.insert("dnp".into(), json!(true));+        ch.insert("excludeFromBom".into(), json!(true));+        let a = set_attrs(&v, &ch).unwrap();+        let fp = sxedit::parse(&a).unwrap();+        assert_eq!(attrs(&fp), json!({"type":"smd","excludeFromPosFiles":false,"excludeFromBom":true,"dnp":true,"boardOnly":false,"allowMissingCourtyard":false,"allowSoldermaskBridges":false}));+        let l = set_locked(&a, true).unwrap();+        assert!(is_locked(&sxedit::parse(&l).unwrap()));+        assert!(!is_locked(&sxedit::parse(&set_locked(&l, false).unwrap()).unwrap()));+        assert!(remove_property(&v, "Value").is_err());+        let r = remove_property(&v, "LCSC").unwrap();+        assert!(!r.contains("LCSC"));+    }++    #[test]+    fn graphics_and_models_list_and_edit() {+        let fp = sxedit::parse(CAP).unwrap();+        let g = graphics(&fp);+        assert_eq!(g.len(), 2);+        assert_eq!(g[0]["kind"], "line");+        assert_eq!(g[1]["points"].as_array().unwrap().len(), 3);+        let e = edit_graphic(CAP, "u-l1", &json!({"width": 0.2, "layer": "F.Fab", "end": [0.3, -0.73]})).unwrap();+        let g = graphics(&sxedit::parse(&e).unwrap());+        assert_eq!((g[0]["width"].clone(), g[0]["layer"].clone(), g[0]["end"].clone()), (json!(0.2), json!("F.Fab"), json!([0.3, -0.73])));+        assert!(edit_graphic(CAP, "nope", &json!({"width": 1})).is_err());+        let d = edit_graphic(CAP, "u-cy", &json!({"delete": true})).unwrap();+        assert_eq!(graphics(&sxedit::parse(&d).unwrap()).len(), 1);+        let m = edit_models(CAP, &[json!({"action":"replace","index":0,"offset":[0,0,0.5]}), json!({"action":"add","path":"${KIPRJMOD}/y.step","rotate":[0,0,90]})]).unwrap();+        let ms = models(&sxedit::parse(&m).unwrap());+        assert_eq!(ms.len(), 2);+        assert_eq!(ms[0]["offset"], json!([0.0, 0.0, 0.5]));+        assert_eq!(ms[0]["rotate"], json!([0.0, 0.0, 90.0]));+        assert_eq!(ms[1]["path"], "${KIPRJMOD}/y.step");+        let r = edit_models(&m, &[json!({"action":"remove","path":"x.step"})]).unwrap();+        assert_eq!(models(&sxedit::parse(&r).unwrap()).len(), 1);+    }++    #[test]+    fn a_library_footprint_lands_on_the_back_with_fresh_ids() {+        let lib = "(footprint \"R_0603\" (version 20240108) (generator \"pcbnew\") (layer \"F.Cu\") (descr \"r\")\n (property \"Reference\" \"REF**\" (at 0 -1.4 0) (layer \"F.SilkS\") (uuid \"aaa\") (effects (font (size 1 1))))\n (property \"Value\" \"R_0603\" (at 0 1.4 0) (layer \"F.Fab\") (uuid \"bbb\") (effects (font (size 1 1))))\n (attr smd)\n (pad \"1\" smd roundrect (at -0.8 0) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (uuid \"ccc\"))\n (pad \"2\" smd roundrect (at 0.8 0) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (uuid \"ddd\"))\n)";+        let out = place_library(lib, "Resistor_SMD:R_0603", 100.0, 50.0, 90.0, true, "R7", Some("10k"), 2).unwrap();+        let fp = sxedit::parse(&out).unwrap();+        assert_eq!(fp.atom(1), Some("Resistor_SMD:R_0603"));+        assert_eq!(fp.value_of("layer"), Some("B.Cu"));+        assert_eq!(pose(&fp), (100.0, 50.0, 90.0));+        assert!(fp.kid("version").is_none() && fp.kid("generator").is_none());+        assert!(!out.contains("\"ccc\"") && !out.contains("\"aaa\""));+        assert!(properties(&fp).contains(&("Reference".into(), "R7".into())));+        let p1 = fp.kids_tagged("pad").next().unwrap();+        // flipped left/right at rotation 0 (orientation 180), then turned to 90: pad angle 180 - 0 + (90 - 180)+        assert_eq!(p1.kid("at").unwrap().f64_at(3), Some(90.0));+        assert_eq!(p1.kid("layers").unwrap().atom(1), Some("B.Cu"));+        // parses as a board footprint+        let board = format!("(kicad_pcb (version 20260206) (generator \"t\") {out})");+        let b = crate::pcb::parse_pcb_text(&board, "t.kicad_pcb").unwrap();+        assert_eq!(b.footprints[0].reference, "R7");+    }+}
rust/crates/kicad-core/src/fpedit.rsadded+831
@@ -0,0 +1,831 @@+//! Footprint authoring on `.kicad_mod` files (KiCad 10 format): create one from a pad+//! list (courtyard, fab and silk outlines, reference and value generated), read one back+//! in full, and edit pads, graphics, metadata and 3D models in place. Edits go through the+//! lossless document model in `crate::schematic`, so everything an edit does not touch+//! stays byte-identical; new nodes are laid out the way KiCad's formatter does.++use serde_json::{json, Value};++use crate::pcb::num;+use crate::schematic::{new_uuid, parse_node, pretty, quote, Document, Kind, Node};++pub const FP_VERSION: &str = "20260206";+const GENERATOR: &str = "adom_kicad_bridge";++fn xy(v: Option<&Value>) -> Option<(f64, f64)> {+    match v? {+        Value::Array(a) if a.len() >= 2 => Some((a[0].as_f64()?, a[1].as_f64()?)),+        Value::Object(o) => Some((o.get("x")?.as_f64()?, o.get("y")?.as_f64()?)),+        _ => None,+    }+}++fn xyz(v: Option<&Value>, default: f64) -> Result<[f64; 3], String> {+    match v {+        None | Some(Value::Null) => Ok([default; 3]),+        Some(Value::Array(a)) if a.len() == 3 => {+            let mut out = [0.0; 3];+            for (i, x) in a.iter().enumerate() {+                out[i] = x.as_f64().ok_or("xyz values must be numbers")?;+            }+            Ok(out)+        }+        Some(Value::Object(o)) => Ok([o.get("x").and_then(Value::as_f64).unwrap_or(default), o.get("y").and_then(Value::as_f64).unwrap_or(default), o.get("z").and_then(Value::as_f64).unwrap_or(default)]),+        _ => Err("offset, scale and rotate take [x, y, z] or {x, y, z}".into()),+    }+}++// ---------------------------------------------------------------------------+// Pads+// ---------------------------------------------------------------------------++#[derive(Clone, Debug, PartialEq)]+pub struct PadSpec {+    pub number: String,+    pub kind: String,+    pub shape: String,+    pub at: (f64, f64),+    pub rotation: f64,+    pub size: (f64, f64),+    pub drill: Option<(f64, f64)>,+    pub layers: Vec<String>,+    pub rratio: Option<f64>,+}++pub const PAD_TYPES: &[&str] = &["smd", "thru_hole", "np_thru_hole", "connect"];+pub const PAD_SHAPES: &[&str] = &["circle", "rect", "oval", "roundrect", "trapezoid"];++/// One pad from the verb's JSON: {number, type, shape, size [w,h] | d, drill d | [w,h],+/// at [x,y], rotation, layers, roundrectRatio}. Defaults: smd roundrect 0.25, layers by type.+pub fn pad_from_json(v: &Value, side_back: bool) -> Result<PadSpec, String> {+    let number = match v.get("number") {+        Some(Value::String(s)) => s.clone(),+        Some(Value::Number(n)) => n.to_string(),+        None | Some(Value::Null) => String::new(),+        _ => return Err("pad number must be a string or a number".into()),+    };+    let kind = v.get("type").and_then(Value::as_str).unwrap_or("smd").to_string();+    if !PAD_TYPES.contains(&kind.as_str()) {+        return Err(format!("pad {number}: type must be one of {PAD_TYPES:?}"));+    }+    let shape = v.get("shape").and_then(Value::as_str).unwrap_or(if kind == "smd" { "roundrect" } else { "circle" }).to_string();+    if !PAD_SHAPES.contains(&shape.as_str()) {+        return Err(format!("pad {number}: shape must be one of {PAD_SHAPES:?}"));+    }+    let size = match v.get("size") {+        Some(Value::Number(n)) => {+            let d = n.as_f64().unwrap_or(0.0);+            (d, d)+        }+        other => xy(other).ok_or_else(|| format!("pad {number}: size [w, h] (mm) is required"))?,+    };+    if size.0 <= 0.0 || size.1 <= 0.0 {+        return Err(format!("pad {number}: size must be positive"));+    }+    let drill = match v.get("drill") {+        None | Some(Value::Null) => None,+        Some(Value::Number(n)) => n.as_f64().map(|d| (d, d)),+        other => Some(xy(other).ok_or_else(|| format!("pad {number}: drill is a diameter or [w, h]"))?),+    };+    if kind != "smd" && kind != "connect" && drill.is_none() {+        return Err(format!("pad {number}: a {kind} pad needs a drill"));+    }+    if let Some(d) = drill {+        if d.0 <= 0.0 || d.1 <= 0.0 {+            return Err(format!("pad {number}: drill must be positive"));+        }+        if kind == "thru_hole" && (d.0 >= size.0 || d.1 >= size.1) {+            return Err(format!("pad {number}: the drill ({}) must be smaller than the pad ({} x {})", num(d.0), num(size.0), num(size.1)));+        }+    }+    let at = xy(v.get("at").or_else(|| v.get("position"))).unwrap_or((0.0, 0.0));+    let rotation = v.get("rotation").and_then(Value::as_f64).unwrap_or(0.0);+    let f = if side_back { "B" } else { "F" };+    let layers = match v.get("layers") {+        Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),+        _ => match kind.as_str() {+            "smd" => vec![format!("{f}.Cu"), format!("{f}.Mask"), format!("{f}.Paste")],+            "connect" => vec![format!("{f}.Cu"), format!("{f}.Mask")],+            "thru_hole" => vec!["*.Cu".into(), "*.Mask".into()],+            _ => vec!["*.Cu".into(), "*.Mask".into()],+        },+    };+    let rratio = if shape == "roundrect" { Some(v.get("roundrectRatio").and_then(Value::as_f64).unwrap_or(0.25).clamp(0.0, 0.5)) } else { None };+    Ok(PadSpec { number, kind, shape, at, rotation, size, drill, layers, rratio })+}++pub fn pad_text(p: &PadSpec) -> String {+    let rot = if p.rotation != 0.0 { format!(" {}", num(p.rotation)) } else { String::new() };+    let drill = match p.drill {+        Some((a, b)) if (a - b).abs() > 1e-9 => format!("(drill oval {} {})", num(a), num(b)),+        Some((a, _)) => format!("(drill {})", num(a)),+        None => String::new(),+    };+    let layers: Vec<String> = p.layers.iter().map(|l| quote(l)).collect();+    let rr = p.rratio.map(|r| format!("(roundrect_rratio {})", num(r))).unwrap_or_default();+    let extra = if p.kind == "thru_hole" { "(remove_unused_layers no)" } else { "" };+    format!(+        "(pad {} {} {} (at {} {}{rot}) (size {} {}) {drill} (layers {}) {extra} {rr} (uuid \"{}\"))",+        quote(&p.number), p.kind, p.shape, num(p.at.0), num(p.at.1), num(p.size.0), num(p.size.1), layers.join(" "), new_uuid()+    )+}++/// The axis-aligned box of a pad (rotation applied).+fn pad_box(p: &PadSpec) -> (f64, f64, f64, f64) {+    let a = p.rotation.to_radians();+    let (hw, hh) = (p.size.0 / 2.0, p.size.1 / 2.0);+    let ex = (hw * a.cos()).abs() + (hh * a.sin()).abs();+    let ey = (hw * a.sin()).abs() + (hh * a.cos()).abs();+    (p.at.0 - ex, p.at.1 - ey, p.at.0 + ex, p.at.1 + ey)+}++fn union(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)) -> (f64, f64, f64, f64) {+    (a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3))+}++fn snap_out(b: (f64, f64, f64, f64), grid: f64) -> (f64, f64, f64, f64) {+    ((b.0 / grid).floor() * grid, (b.1 / grid).floor() * grid, (b.2 / grid).ceil() * grid, (b.3 / grid).ceil() * grid)+}++/// Node from compact text, laid out at depth 1 (a direct child of the footprint).+fn node(text: &str) -> Result<Node, String> {+    let mut n = parse_node(text)?;+    pretty(&mut n, 1);+    Ok(n)+}++// ---------------------------------------------------------------------------+// Graphics+// ---------------------------------------------------------------------------++/// One graphic from JSON: {type: line|arc|rect|circle|poly, start, end, mid, center, points,+/// width, fill}. Returns the fp_* text on `layer`.+pub fn graphic_text(v: &Value, layer: &str, default_width: f64) -> Result<String, String> {+    let t = v.get("type").and_then(Value::as_str).ok_or("each shape needs a type: line, arc, rect, circle or poly")?;+    let w = v.get("width").and_then(Value::as_f64).unwrap_or(default_width);+    let fill = match v.get("fill") {+        Some(Value::Bool(true)) => "yes",+        Some(Value::String(s)) if s == "yes" || s == "solid" => "yes",+        _ => "no",+    };+    let stroke = format!("(stroke (width {}) (type solid))", num(w));+    let p = |k: &str| xy(v.get(k)).ok_or_else(|| format!("{t}: {k} [x, y] is required"));+    let uuid = new_uuid();+    let lay = quote(layer);+    Ok(match t {+        "line" => {+            let (a, b) = (p("start")?, p("end")?);+            format!("(fp_line (start {} {}) (end {} {}) {stroke} (layer {lay}) (uuid \"{uuid}\"))", num(a.0), num(a.1), num(b.0), num(b.1))+        }+        "arc" => {+            let (a, m, b) = (p("start")?, p("mid")?, p("end")?);+            format!("(fp_arc (start {} {}) (mid {} {}) (end {} {}) {stroke} (layer {lay}) (uuid \"{uuid}\"))", num(a.0), num(a.1), num(m.0), num(m.1), num(b.0), num(b.1))+        }+        "rect" => {+            let (a, b) = (p("start")?, p("end")?);+            format!("(fp_rect (start {} {}) (end {} {}) {stroke} (fill {fill}) (layer {lay}) (uuid \"{uuid}\"))", num(a.0), num(a.1), num(b.0), num(b.1))+        }+        "circle" => {+            let c = p("center")?;+            let e = match (xy(v.get("end")), v.get("radius").and_then(Value::as_f64)) {+                (Some(e), _) => e,+                (None, Some(r)) => (c.0 + r, c.1),+                _ => return Err("circle: end [x, y] or radius is required".into()),+            };+            format!("(fp_circle (center {} {}) (end {} {}) {stroke} (fill {fill}) (layer {lay}) (uuid \"{uuid}\"))", num(c.0), num(c.1), num(e.0), num(e.1))+        }+        "poly" => {+            let pts: Vec<(f64, f64)> = v.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(|q| xy(Some(q))).collect()).unwrap_or_default();+            if pts.len() < 3 {+                return Err("poly: points needs at least three [x, y]".into());+            }+            let xys: Vec<String> = pts.iter().map(|(x, y)| format!("(xy {} {})", num(*x), num(*y))).collect();+            format!("(fp_poly (pts {}) {stroke} (fill {fill}) (layer {lay}) (uuid \"{uuid}\"))", xys.join(" "))+        }+        other => return Err(format!("unknown shape type {other}: line, arc, rect, circle or poly")),+    })+}++fn rect_text(b: (f64, f64, f64, f64), layer: &str, width: f64) -> String {+    format!("(fp_rect (start {} {}) (end {} {}) (stroke (width {}) (type solid)) (fill no) (layer {}) (uuid \"{}\"))", num(b.0), num(b.1), num(b.2), num(b.3), num(width), quote(layer), new_uuid())+}++fn property_text(name: &str, value: &str, at: (f64, f64), layer: &str, hide: bool, size: f64) -> String {+    format!(+        "(property {} {} (at {} {} 0) (layer {}){} (uuid \"{}\") (effects (font (size {} {}) (thickness {}))))",+        quote(name), quote(value), num(at.0), num(at.1), quote(layer), if hide { " (hide yes)" } else { "" }, new_uuid(), num(size), num(size), num((size * 0.15 * 100.0).round() / 100.0)+    )+}++fn model_text(m: &Value) -> Result<String, String> {+    let path = m.get("path").and_then(Value::as_str).filter(|p| !p.trim().is_empty()).ok_or("a model needs a path")?;+    let o = xyz(m.get("offset"), 0.0)?;+    let s = xyz(m.get("scale"), 1.0)?;+    let r = xyz(m.get("rotate").or_else(|| m.get("rotation")), 0.0)?;+    let t = |v: [f64; 3]| format!("(xyz {} {} {})", num(v[0]), num(v[1]), num(v[2]));+    Ok(format!("(model {} (offset {}) (scale {}) (rotate {}))", quote(path), t(o), t(s), t(r)))+}++// ---------------------------------------------------------------------------+// Create+// ---------------------------------------------------------------------------++/// A complete .kicad_mod from the verb's args. Returns (text, summary).+pub fn create(name: &str, args: &Value) -> Result<(String, Value), String> {+    if name.trim().is_empty() || name.contains(['/', '\\', ':', '"']) {+        return Err("name must be a plain footprint name (no / \\ : or quotes)".into());+    }+    let back = args.get("side").and_then(Value::as_str) == Some("back");+    let pads_v = args.get("pads").and_then(Value::as_array).ok_or("pads is required: a list of {number, type, shape, size, at, drill?}")?;+    if pads_v.is_empty() {+        return Err("pads is empty".into());+    }+    let pads: Vec<PadSpec> = pads_v.iter().map(|p| pad_from_json(p, back)).collect::<Result<_, _>>()?;+    let pad_bb = pads.iter().map(pad_box).reduce(union).unwrap();+    let body = match xy(args.get("bodySize")) {+        Some((w, h)) => (-w / 2.0, -h / 2.0, w / 2.0, h / 2.0),+        None => pad_bb,+    };+    let margin = args.get("courtyardMargin").and_then(Value::as_f64).unwrap_or(0.25).max(0.0);+    let all = union(pad_bb, body);+    let silk_on = args.get("silk").and_then(Value::as_bool).unwrap_or(true);+    // Silkscreen box: 0.2 mm clear of every pad (never over copper), stroke 0.12 mm.+    let silk_box = union(body, (pad_bb.0 - 0.26, pad_bb.1 - 0.26, pad_bb.2 + 0.26, pad_bb.3 + 0.26));+    let mut crt = (all.0 - margin, all.1 - margin, all.2 + margin, all.3 + margin);+    if silk_on {+        // the courtyard also encloses the silkscreen (and the pin-1 dot left of it)+        crt = union(crt, (silk_box.0 - 0.5, silk_box.1 - 0.12, silk_box.2 + 0.12, silk_box.3 + 0.12));+    }+    let crt = snap_out(crt, 0.01);+    let f = if back { "B" } else { "F" };+    let smd = pads.iter().all(|p| p.kind == "smd" || p.kind == "connect");+    let attr = match args.get("attr").and_then(Value::as_str) {+        Some(a) if ["smd", "through_hole", "unspecified"].contains(&a) => a.to_string(),+        Some(a) => return Err(format!("attr must be smd, through_hole or unspecified (got {a})")),+        None => if smd { "smd".into() } else { "through_hole".into() },+    };+    let mut parts: Vec<String> = Vec::new();+    parts.push(format!("(version {FP_VERSION})"));+    parts.push(format!("(generator {})", quote(GENERATOR)));+    parts.push("(generator_version \"10.0\")".into());+    parts.push(format!("(layer \"{f}.Cu\")"));+    if let Some(d) = args.get("description").and_then(Value::as_str) {+        parts.push(format!("(descr {})", quote(d)));+    }+    if let Some(t) = args.get("tags") {+        let t = match t {+            Value::Array(a) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "),+            Value::String(s) => s.clone(),+            _ => String::new(),+        };+        if !t.is_empty() {+            parts.push(format!("(tags {})", quote(&t)));+        }+    }+    let text_size = 1.0;+    parts.push(property_text("Reference", args.get("reference").and_then(Value::as_str).unwrap_or("REF**"), (0.0, crt.1 - 0.8), &format!("{f}.SilkS"), false, text_size));+    parts.push(property_text("Value", args.get("value").and_then(Value::as_str).unwrap_or(name), (0.0, crt.3 + 0.8), &format!("{f}.Fab"), false, text_size));+    parts.push(property_text("Datasheet", args.get("datasheet").and_then(Value::as_str).unwrap_or(""), (0.0, 0.0), &format!("{f}.Fab"), true, text_size));+    parts.push(property_text("Description", args.get("description").and_then(Value::as_str).unwrap_or(""), (0.0, 0.0), &format!("{f}.Fab"), true, text_size));+    parts.push(format!("(attr {attr})"));+    parts.push("(duplicate_pad_numbers_are_jumpers no)".into());+    // Silkscreen: a box clear of every pad by 0.2 mm (never over copper), and a pin-1 dot.+    if silk_on {+        let sb = silk_box;+        parts.push(rect_text(sb, &format!("{f}.SilkS"), 0.12));+        if let Some(p1) = pads.iter().find(|p| p.number == "1") {+            let (cx, cy) = (sb.0 - 0.3, p1.at.1.clamp(sb.1, sb.3));+            parts.push(format!("(fp_circle (center {} {}) (end {} {}) (stroke (width 0.12) (type solid)) (fill yes) (layer \"{f}.SilkS\") (uuid \"{}\"))", num(cx), num(cy), num(cx + 0.1), num(cy), new_uuid()));+        }+    }+    parts.push(rect_text(body, &format!("{f}.Fab"), 0.1));+    parts.push(rect_text(crt, &format!("{f}.CrtYd"), 0.05));+    parts.push(format!("(fp_text user \"${{REFERENCE}}\" (at 0 0 0) (layer \"{f}.Fab\") (uuid \"{}\") (effects (font (size 0.5 0.5) (thickness 0.08))))", new_uuid()));+    for p in &pads {+        parts.push(pad_text(p));+    }+    parts.push("(embedded_fonts no)".into());+    if let Some(m) = args.get("model") {+        if !m.is_null() {+            parts.push(model_text(m)?);+        }+    }+    let mut root = parse_node(&format!("(footprint {} {})", quote(name), parts.join(" ")))?;+    pretty(&mut root, 0);+    root.lead.clear();+    let text = root.to_text() + "\n";+    crate::lint::check_footprint_structure(&text)?;+    let info = info(&text)?;+    Ok((text, json!({"pads": pads.len(), "attr": attr, "courtyard": {"min": [crt.0, crt.1], "max": [crt.2, crt.3]}, "info": info})))+}++// ---------------------------------------------------------------------------+// Info+// ---------------------------------------------------------------------------++fn nums(n: &Node) -> Vec<f64> {+    n.children().iter().skip(1).filter_map(|c| c.value()?.parse().ok()).collect()+}++/// Full details of one footprint file.+pub fn info(text: &str) -> Result<Value, String> {+    let doc = Document::parse(text)?;+    let r = &doc.root;+    if r.head() != Some("footprint") && r.head() != Some("module") {+        return Err(format!("not a footprint (top-level list is {:?})", r.head()));+    }+    let mut props = serde_json::Map::new();+    let mut pads = Vec::new();+    let mut graphics: std::collections::BTreeMap<String, usize> = Default::default();+    let mut layers: std::collections::BTreeSet<String> = Default::default();+    let mut crt: Option<(f64, f64, f64, f64)> = None;+    let mut models = Vec::new();+    let grow = |b: &mut Option<(f64, f64, f64, f64)>, x: f64, y: f64| {+        *b = Some(match *b {+            None => (x, y, x, y),+            Some(o) => (o.0.min(x), o.1.min(y), o.2.max(x), o.3.max(y)),+        });+    };+    for c in r.children() {+        match c.head() {+            Some("property") => {+                if let (Some(k), Some(v)) = (c.arg(0), c.arg(1)) {+                    props.insert(k, json!(v));+                }+            }+            Some("pad") => {+                let at = c.child("at").map(nums).unwrap_or_default();+                let size = c.child("size").map(nums).unwrap_or_default();+                let drill = c.child("drill").map(nums);+                let pl: Vec<String> = c.child("layers").map(|l| l.children().iter().skip(1).filter_map(Node::value).collect()).unwrap_or_default();+                layers.extend(pl.iter().cloned());+                pads.push(json!({+                    "number": c.arg(0), "type": c.arg(1), "shape": c.arg(2),+                    "at": at.get(..2), "rotation": at.get(2).copied().unwrap_or(0.0),+                    "size": size, "drill": drill, "layers": pl,+                    "roundrectRatio": c.child("roundrect_rratio").and_then(|n| n.num(0)),+                }));+            }+            Some(h) if h.starts_with("fp_") => {+                let layer = c.child("layer").and_then(|l| l.arg(0)).unwrap_or_default();+                layers.insert(layer.clone());+                *graphics.entry(format!("{layer} {}", &h[3..])).or_default() += 1;+                if layer.ends_with("CrtYd") {+                    for k in ["start", "end", "mid", "center"] {+                        if let Some(p) = c.child(k) {+                            if let (Some(x), Some(y)) = (p.num(0), p.num(1)) {+                                grow(&mut crt, x, y);+                            }+                        }+                    }+                    if h == "fp_circle" {+                        if let (Some(cn), Some(e)) = (c.child("center"), c.child("end")) {+                            let (cx, cy) = (cn.num(0).unwrap_or(0.0), cn.num(1).unwrap_or(0.0));+                            let rr = ((e.num(0).unwrap_or(0.0) - cx).powi(2) + (e.num(1).unwrap_or(0.0) - cy).powi(2)).sqrt();+                            grow(&mut crt, cx - rr, cy - rr);+                            grow(&mut crt, cx + rr, cy + rr);+                        }+                    }+                    if let Some(pts) = c.child("pts") {+                        for q in pts.children().iter().filter(|q| q.head() == Some("xy")) {+                            if let (Some(x), Some(y)) = (q.num(0), q.num(1)) {+                                grow(&mut crt, x, y);+                            }+                        }+                    }+                }+            }+            Some("model") => {+                let t = |k: &str| c.child(k).and_then(|o| o.child("xyz")).map(nums);+                models.push(json!({"path": c.arg(0), "offset": t("offset"), "scale": t("scale"), "rotate": t("rotate"), "hidden": c.child("hide").is_some()}));+            }+            _ => {}+        }+    }+    let attrs: Vec<String> = r.child("attr").map(|a| a.children().iter().skip(1).filter_map(Node::value).collect()).unwrap_or_default();+    Ok(json!({+        "name": r.arg(0),+        "version": r.child_value("version"),+        "layer": r.child_value("layer"),+        "description": r.child_value("descr"),+        "tags": r.child_value("tags"),+        "attributes": attrs,+        "properties": props,+        "padCount": pads.len(),+        "pads": pads,+        "layers": layers.into_iter().filter(|l| !l.is_empty()).collect::<Vec<_>>(),+        "graphics": graphics,+        "courtyard": crt.map(|b| json!({"min": [b.0, b.1], "max": [b.2, b.3], "widthMm": ((b.2 - b.0) * 1e6).round() / 1e6, "heightMm": ((b.3 - b.1) * 1e6).round() / 1e6})),+        "models": models,+    }))+}++// ---------------------------------------------------------------------------+// Edit+// ---------------------------------------------------------------------------++fn kids(root: &mut Node) -> &mut Vec<Node> {+    root.children_mut().expect("root is a list")+}++/// Index after the last child whose head is in `after`; else before the first in `before`;+/// else before the closing paren.+fn insert_at(root: &Node, after: &[&str], before: &[&str]) -> usize {+    let c = root.children();+    if let Some(i) = c.iter().rposition(|n| n.head().map(|h| after.contains(&h)).unwrap_or(false)) {+        return i + 1;+    }+    if let Some(i) = c.iter().position(|n| n.head().map(|h| before.contains(&h)).unwrap_or(false)) {+        return i;+    }+    c.len()+}++const GRAPHIC_HEADS: &[&str] = &["fp_line", "fp_arc", "fp_rect", "fp_circle", "fp_poly", "fp_curve"];++fn set_child(root: &mut Node, head: &str, text: &str, after: &[&str]) -> Result<(), String> {+    let new = node(text)?;+    if let Some(i) = root.children().iter().position(|c| c.head() == Some(head)) {+        let lead = root.children()[i].lead.clone();+        kids(root)[i] = new;+        kids(root)[i].lead = lead;+    } else {+        let i = insert_at(root, after, &["property", "attr", "fp_line", "pad"]);+        kids(root).insert(i, new);+    }+    Ok(())+}++/// Apply the edit operations in `args` to footprint text. Returns (new text, changes).+/// Operations: pads (edit, delete, add), renumber, graphics, metadata, models.+pub fn edit(text: &str, args: &Value) -> Result<(String, Vec<String>), String> {+    let mut doc = Document::parse(text)?;+    if doc.root.head() != Some("footprint") {+        return Err("not a KiCad 6+ footprint (the top-level list must be footprint)".into());+    }+    let mut changes: Vec<String> = Vec::new();+    let back = doc.root.child_value("layer").as_deref() == Some("B.Cu");++    // pads: [{number, delete?, set?:{...}}], addPads: [padSpec]+    if let Some(list) = args.get("pads").and_then(Value::as_array) {+        for op in list {+            let want = match op.get("number") {+                Some(Value::String(s)) => s.clone(),+                Some(Value::Number(n)) => n.to_string(),+                _ => return Err("each pads entry needs the number of the pad it edits".into()),+            };+            let idxs: Vec<usize> = doc.root.children().iter().enumerate().filter(|(_, c)| c.head() == Some("pad") && c.arg(0).as_deref() == Some(want.as_str())).map(|(i, _)| i).collect();+            if idxs.is_empty() {+                return Err(format!("no pad numbered {want}"));+            }+            if op.get("delete").and_then(Value::as_bool).unwrap_or(false) {+                for i in idxs.iter().rev() {+                    kids(&mut doc.root).remove(*i);+                }+                changes.push(format!("deleted pad {want} ({} pad(s))", idxs.len()));+                continue;+            }+            let set = op.get("set").unwrap_or(op);+            for &i in &idxs {+                let pad = &mut kids(&mut doc.root)[i];+                let mut cur = pad_from_node(pad)?;+                if let Some(v) = set.get("newNumber").or_else(|| set.get("renumber")) {+                    cur.number = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());+                }+                let merged = merge_pad(&cur, set, back)?;+                let mut fresh = node(&pad_text(&merged))?;+                // keep the pad's uuid and any child the bridge does not model (net, pinfunction, ...)+                carry_unmodelled(pad, &mut fresh);+                fresh.lead = pad.lead.clone();+                *pad = fresh;+            }+            changes.push(format!("edited pad {want}"));+        }+    }+    if let Some(list) = args.get("addPads").and_then(Value::as_array) {+        for p in list {+            let spec = pad_from_json(p, back)?;+            let i = insert_at(&doc.root, &["pad"], &["embedded_fonts", "model", "zone", "group"]);+            kids(&mut doc.root).insert(i, node(&pad_text(&spec))?);+            changes.push(format!("added pad {}", spec.number));+        }+    }+    // renumber: {"map": {"1": "A1"}} or {"sequential": true, "start": 1}+    if let Some(r) = args.get("renumber") {+        let mut n = r.get("start").and_then(Value::as_u64).unwrap_or(1);+        let map = r.get("map").and_then(Value::as_object).cloned().unwrap_or_default();+        let seq = r.get("sequential").and_then(Value::as_bool).unwrap_or(false);+        if map.is_empty() && !seq {+            return Err("renumber needs map {old: new} or sequential:true".into());+        }+        let mut count = 0;+        for c in kids(&mut doc.root).iter_mut().filter(|c| c.head() == Some("pad")) {+            let old = c.arg(0).unwrap_or_default();+            if old.is_empty() && seq {+                continue; // unnumbered mechanical pads keep no number+            }+            let new = if seq {+                let v = n.to_string();+                n += 1;+                Some(v)+            } else {+                map.get(&old).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))+            };+            if let Some(v) = new {+                c.set_arg(0, Node::quoted(&v));+                count += 1;+            }+        }+        changes.push(format!("renumbered {count} pad(s)"));+    }+    // graphics: {layer, mode: append|replace|delete, shapes: [...], width?}+    if let Some(gs) = args.get("graphics") {+        let ops: Vec<&Value> = match gs {+            Value::Array(a) => a.iter().collect(),+            v => vec![v],+        };+        for g in ops {+            let layer = g.get("layer").and_then(Value::as_str).ok_or("graphics needs a layer (F.SilkS, F.Fab, F.CrtYd, ...)")?;+            let mode = g.get("mode").and_then(Value::as_str).unwrap_or("append");+            if !["append", "replace", "delete"].contains(&mode) {+                return Err("graphics mode is append, replace or delete".into());+            }+            if mode != "append" {+                let before = doc.root.children().len();+                kids(&mut doc.root).retain(|c| !(c.head().map(|h| GRAPHIC_HEADS.contains(&h)).unwrap_or(false) && c.child("layer").and_then(|l| l.arg(0)).as_deref() == Some(layer)));+                changes.push(format!("removed {} graphic(s) on {layer}", before - doc.root.children().len()));+            }+            if mode != "delete" {+                let default_w = if layer.ends_with("CrtYd") { 0.05 } else if layer.ends_with("SilkS") || layer.ends_with("Silkscreen") { 0.12 } else { 0.1 };+                let shapes = g.get("shapes").and_then(Value::as_array).ok_or("graphics needs shapes")?;+                for s in shapes {+                    let t = graphic_text(s, layer, g.get("width").and_then(Value::as_f64).unwrap_or(default_w))?;+                    let i = insert_at(&doc.root, GRAPHIC_HEADS, &["fp_text", "fp_text_box", "pad", "embedded_fonts", "model"]);+                    kids(&mut doc.root).insert(i, node(&t)?);+                }+                changes.push(format!("added {} shape(s) on {layer}", shapes.len()));+            }+        }+    }+    // metadata: {description, tags, attributes: {type, boardOnly, excludeFromPos, excludeFromBom, dnp} | [..]}+    if let Some(m) = args.get("metadata") {+        if let Some(d) = m.get("description").and_then(Value::as_str) {+            set_child(&mut doc.root, "descr", &format!("(descr {})", quote(d)), &["layer", "uuid", "at"])?;+            // KiCad 8+ also keeps the description as a property.+            if let Some(p) = kids(&mut doc.root).iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some("Description")) {+                p.set_arg(1, Node::quoted(d));+            }+            changes.push("description".into());+        }+        if let Some(t) = m.get("tags") {+            let t = match t {+                Value::Array(a) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "),+                Value::String(s) => s.clone(),+                _ => String::new(),+            };+            set_child(&mut doc.root, "tags", &format!("(tags {})", quote(&t)), &["descr", "layer"])?;+            changes.push("tags".into());+        }+        if let Some(a) = m.get("attributes") {+            let atoms: Vec<String> = match a {+                Value::Array(list) => list.iter().filter_map(Value::as_str).map(str::to_string).collect(),+                Value::Object(o) => {+                    let mut v = Vec::new();+                    let ty = o.get("type").and_then(Value::as_str).unwrap_or("smd");+                    if ty != "unspecified" {+                        v.push(ty.to_string());+                    }+                    for (k, atom) in [("boardOnly", "board_only"), ("excludeFromPos", "exclude_from_pos_files"), ("excludeFromBom", "exclude_from_bom"), ("allowMissingCourtyard", "allow_missing_courtyard"), ("dnp", "dnp")] {+                        if o.get(k).and_then(Value::as_bool).unwrap_or(false) {+                            v.push(atom.into());+                        }+                    }+                    v+                }+                _ => return Err("attributes is a list of KiCad attr atoms or {type, boardOnly, excludeFromPos, excludeFromBom, dnp}".into()),+            };+            const OK: &[&str] = &["smd", "through_hole", "board_only", "exclude_from_pos_files", "exclude_from_bom", "allow_missing_courtyard", "dnp", "allow_soldermask_bridges"];+            if let Some(bad) = atoms.iter().find(|a| !OK.contains(&a.as_str())) {+                return Err(format!("unknown attribute {bad}; known: {OK:?}"));+            }+            if atoms.is_empty() {+                kids(&mut doc.root).retain(|c| c.head() != Some("attr"));+            } else {+                set_child(&mut doc.root, "attr", &format!("(attr {})", atoms.join(" ")), &["property"])?;+            }+            changes.push(format!("attributes [{}]", atoms.join(" ")));+        }+    }+    // models: [{path, offset, scale, rotate}] with mode replace (default) | append | delete+    if let Some(ms) = args.get("models") {+        let (mode, list): (&str, Vec<Value>) = match ms {+            Value::Array(a) => ("replace", a.clone()),+            Value::Object(o) => (o.get("mode").and_then(Value::as_str).unwrap_or("replace"), o.get("list").and_then(Value::as_array).cloned().unwrap_or_default()),+            _ => return Err("models is a list of {path, offset, scale, rotate} or {mode, list}".into()),+        };+        if !["replace", "append", "delete"].contains(&mode) {+            return Err("models mode is replace, append or delete".into());+        }+        if mode != "append" {+            kids(&mut doc.root).retain(|c| c.head() != Some("model"));+            changes.push("removed existing 3D models".into());+        }+        if mode != "delete" {+            for m in &list {+                let t = model_text(m)?;+                let i = doc.root.children().len();+                kids(&mut doc.root).insert(i, node(&t)?);+            }+            changes.push(format!("added {} 3D model(s)", list.len()));+        }+    }+    if changes.is_empty() {+        return Err("nothing to do: pass pads, addPads, renumber, graphics, metadata or models".into());+    }+    let out = doc.to_text();+    crate::lint::check_footprint_structure(&out)?;+    Document::parse(&out)?;+    Ok((out, changes))+}++/// Read a pad node back into a spec.+fn pad_from_node(n: &Node) -> Result<PadSpec, String> {+    let at = n.child("at").map(nums).unwrap_or_default();+    let size = n.child("size").map(nums).unwrap_or_default();+    let drill = n.child("drill").map(nums).filter(|d| !d.is_empty()).map(|d| (d[0], *d.get(1).unwrap_or(&d[0])));+    Ok(PadSpec {+        number: n.arg(0).unwrap_or_default(),+        kind: n.arg(1).unwrap_or_else(|| "smd".into()),+        shape: n.arg(2).unwrap_or_else(|| "rect".into()),+        at: (*at.first().unwrap_or(&0.0), *at.get(1).unwrap_or(&0.0)),+        rotation: *at.get(2).unwrap_or(&0.0),+        size: (*size.first().unwrap_or(&1.0), *size.get(1).unwrap_or(size.first().unwrap_or(&1.0))),+        drill,+        layers: n.child("layers").map(|l| l.children().iter().skip(1).filter_map(Node::value).collect()).unwrap_or_default(),+        rratio: n.child("roundrect_rratio").and_then(|r| r.num(0)),+    })+}++fn merge_pad(cur: &PadSpec, set: &Value, back: bool) -> Result<PadSpec, String> {+    let mut j = json!({+        "number": cur.number, "type": cur.kind, "shape": cur.shape, "at": [cur.at.0, cur.at.1], "rotation": cur.rotation,+        "size": [cur.size.0, cur.size.1], "layers": cur.layers,+    });+    if let Some(d) = cur.drill {+        j["drill"] = json!([d.0, d.1]);+    }+    if let Some(r) = cur.rratio {+        j["roundrectRatio"] = json!(r);+    }+    for k in ["type", "shape", "at", "position", "rotation", "size", "drill", "layers", "roundrectRatio"] {+        if let Some(v) = set.get(k) {+            j[if k == "position" { "at" } else { k }] = v.clone();+        }+    }+    if set.get("type").and_then(Value::as_str) == Some("smd") && set.get("drill").is_none() {+        j.as_object_mut().unwrap().remove("drill");+        if set.get("layers").is_none() {+            j.as_object_mut().unwrap().remove("layers");+        }+    }+    pad_from_json(&j, back)+}++/// Copy children of `old` that `pad_text` does not write (net, pinfunction, pintype,+/// thermal settings, clearances...) and the original uuid into `new`.+fn carry_unmodelled(old: &Node, new: &mut Node) {+    const MODELLED: &[&str] = &["at", "size", "drill", "layers", "roundrect_rratio", "remove_unused_layers", "uuid"];+    let keep: Vec<Node> = old.children().iter().skip(4).filter(|c| c.is_list() && !c.head().map(|h| MODELLED.contains(&h)).unwrap_or(false)).cloned().collect();+    let old_uuid = old.child("uuid").cloned();+    if let Kind::List { children, .. } = &mut new.kind {+        if let Some(u) = old_uuid {+            if let Some(slot) = children.iter_mut().find(|c| c.head() == Some("uuid")) {+                let lead = slot.lead.clone();+                *slot = u;+                slot.lead = lead;+            }+        }+        let at = children.iter().position(|c| c.head() == Some("uuid")).unwrap_or(children.len());+        for (k, c) in keep.into_iter().enumerate() {+            children.insert(at + k, c);+        }+    }+}++#[cfg(test)]+mod tests {+    use super::*;++    fn soic() -> Value {+        let mut pads = Vec::new();+        for i in 0..4 {+            pads.push(json!({"number": i + 1, "size": [1.5, 0.6], "at": [-2.7, -1.905 + 1.27 * i as f64]}));+            pads.push(json!({"number": 8 - i, "size": [1.5, 0.6], "at": [2.7, -1.905 + 1.27 * i as f64]}));+        }+        json!({"pads": pads, "bodySize": [3.9, 4.9], "description": "SOIC-8 test", "tags": ["soic", "so8"], "model": {"path": "${KICAD10_3DMODEL_DIR}/Package_SO.3dshapes/SOIC-8.step"}})+    }++    #[test]+    fn create_writes_a_parseable_kicad10_footprint_with_courtyard() {+        let (text, summary) = create("SOIC8_Test", &soic()).unwrap();+        assert!(text.starts_with("(footprint \"SOIC8_Test\"\n\t(version 20260206)"));+        crate::lint::check_footprint_structure(&text).unwrap();+        let lint = crate::lint::parse_kicad_mod(&text);+        assert_eq!(lint["padCount"], 8);+        assert_eq!(lint["fileVersion"], 20260206);+        let i = info(&text).unwrap();+        assert_eq!(i["attributes"], json!(["smd"]));+        assert_eq!(i["pads"][0]["shape"], "roundrect");+        assert_eq!(i["pads"][0]["roundrectRatio"], 0.25);+        // courtyard: pads reach x 3.45 (silk box 3.71 plus stroke), body y 2.45 plus 0.25 margin+        let c = &summary["courtyard"];+        assert_eq!(c["max"][0], 3.83);+        assert_eq!(c["max"][1], 2.7);+        assert_eq!(c["min"][0], -4.21, "the pin-1 dot sits left of the silk box");+        assert_eq!(i["models"][0]["scale"], json!([1.0, 1.0, 1.0]));+        assert!(i["layers"].as_array().unwrap().iter().any(|l| l == "F.CrtYd"));+        assert!(i["layers"].as_array().unwrap().iter().any(|l| l == "F.SilkS"));+        assert_eq!(i["tags"], "soic so8");+        // the generated silkscreen never lands on a pad+        let board = format!("(kicad_pcb (version 20260206) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)) {})", text.replacen("(footprint \"SOIC8_Test\"", "(footprint \"L:SOIC8_Test\" (at 10 10) (property \"Reference\" \"U1\")", 1));+        let g = crate::dfm::Geometry::from_text(&board).unwrap();+        assert_eq!(g.pads.len(), 8);+        assert!(g.silk_on_pads().is_empty(), "{:?}", g.silk_on_pads());+    }++    #[test]+    fn create_rejects_bad_pads() {+        assert!(create("X", &json!({"pads": []})).is_err());+        assert!(create("X", &json!({"pads": [{"number": 1, "type": "thru_hole", "size": [1, 1]}]})).unwrap_err().contains("needs a drill"));+        assert!(create("X", &json!({"pads": [{"number": 1, "type": "thru_hole", "size": [1, 1], "drill": 1.2}]})).unwrap_err().contains("smaller"));+        assert!(create("X", &json!({"pads": [{"number": 1, "shape": "hex", "size": [1, 1]}]})).is_err());+        assert!(create("a/b", &soic()).is_err());+        let (tht, _) = create("Pin", &json!({"pads": [{"number": 1, "type": "thru_hole", "shape": "rect", "size": 1.7, "drill": 1.0}, {"number": 2, "type": "thru_hole", "size": 1.7, "drill": 1.0, "at": [2.54, 0]}, {"type": "np_thru_hole", "size": 3.2, "drill": 3.2, "at": [6, 0]}]})).unwrap();+        let i = info(&tht).unwrap();+        assert_eq!(i["attributes"], json!(["through_hole"]));+        assert_eq!(i["pads"][1]["layers"], json!(["*.Cu", "*.Mask"]));+        assert_eq!(i["pads"][2]["number"], "");+    }++    #[test]+    fn edit_pads_graphics_metadata_models_and_keeps_the_rest() {+        let (text, _) = create("SOIC8_Test", &soic()).unwrap();+        // a pad with a child the bridge does not model must keep it+        let text = text.replacen("(layers \"F.Cu\" \"F.Mask\" \"F.Paste\")\n\t\t(roundrect_rratio 0.25)", "(layers \"F.Cu\" \"F.Mask\" \"F.Paste\")\n\t\t(roundrect_rratio 0.25)\n\t\t(solder_mask_margin 0.05)", 1);+        let pad1_uuid = info(&text).unwrap();+        let _ = pad1_uuid;+        let args = json!({+            "pads": [{"number": "1", "set": {"size": [1.6, 0.65], "shape": "rect"}}, {"number": 8, "delete": true}],+            "addPads": [{"number": "EP", "size": [2.2, 3.0]}],+            "graphics": [{"layer": "F.SilkS", "mode": "replace", "shapes": [{"type": "line", "start": [-1, -3], "end": [1, -3]}, {"type": "circle", "center": [-3.5, -2], "radius": 0.1, "fill": true}]},+                         {"layer": "F.Fab", "shapes": [{"type": "poly", "points": [[0, 0], [1, 0], [0, 1]]}, {"type": "arc", "start": [0, 0], "mid": [0.5, 0.5], "end": [1, 0]}]}],+            "metadata": {"description": "edited", "tags": "a b", "attributes": {"type": "smd", "excludeFromBom": true}},+            "models": {"mode": "replace", "list": [{"path": "C:/m/x.step", "offset": [0, 0, 0.1], "rotate": {"z": 90}}]}+        });+        let (out, changes) = edit(&text, &args).unwrap();+        assert!(changes.len() >= 7, "{changes:?}");+        let i = info(&out).unwrap();+        assert_eq!(i["padCount"], 8, "8 - 1 + EP");+        let p1 = i["pads"].as_array().unwrap().iter().find(|p| p["number"] == "1").unwrap();+        assert_eq!(p1["size"], json!([1.6, 0.65]));+        assert_eq!(p1["shape"], "rect");+        assert!(out.contains("(solder_mask_margin 0.05)"), "unmodelled pad child kept");+        assert_eq!(i["description"], "edited");+        assert_eq!(i["attributes"], json!(["smd", "exclude_from_bom"]));+        assert_eq!(i["graphics"]["F.SilkS line"], 1);+        assert_eq!(i["graphics"]["F.SilkS circle"], 1);+        assert!(i["graphics"].get("F.SilkS rect").is_none(), "replace removed the old silk box");+        assert_eq!(i["graphics"]["F.Fab poly"], 1);+        assert_eq!(i["models"].as_array().unwrap().len(), 1);+        assert_eq!(i["models"][0]["rotate"], json!([0.0, 0.0, 90.0]));+        // the courtyard, the reference and the value were not touched+        let before = info(&text).unwrap();+        assert_eq!(before["courtyard"], i["courtyard"]);+        assert_eq!(before["properties"]["Reference"], i["properties"]["Reference"]);+        // renumber+        let (out2, _) = edit(&out, &json!({"renumber": {"map": {"EP": "9"}}})).unwrap();+        assert!(info(&out2).unwrap()["pads"].as_array().unwrap().iter().any(|p| p["number"] == "9"));+        let (out3, _) = edit(&out2, &json!({"renumber": {"sequential": true}})).unwrap();+        let numbers: Vec<String> = info(&out3).unwrap()["pads"].as_array().unwrap().iter().map(|p| p["number"].as_str().unwrap().to_string()).collect();+        assert_eq!(numbers, (1..=8).map(|n| n.to_string()).collect::<Vec<_>>());+        // a no-op edit is refused, an unknown pad is named+        assert!(edit(&text, &json!({})).is_err());+        assert!(edit(&text, &json!({"pads": [{"number": "99", "delete": true}]})).unwrap_err().contains("99"));+        // an untouched region is byte-identical+        let head_end = text.find("(attr").unwrap();+        assert_eq!(&out3[..30], &text[..30]);+        assert!(out3.contains(&text[..head_end].lines().take(4).collect::<Vec<_>>().join("\n")));+    }++    #[test]+    fn info_reads_a_stock_style_footprint() {+        let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../pcm/src/adom-sample-lib/footprints/Adom_Sample.pretty/AdomSample_SOIC-8_3.9x4.9mm_P1.27mm.kicad_mod");+        let i = info(&std::fs::read_to_string(p).unwrap()).unwrap();+        assert_eq!(i["padCount"], 8);+        assert_eq!(i["attributes"], json!(["smd"]));+        assert!(i["courtyard"].is_object());+        assert_eq!(i["models"].as_array().unwrap().len(), 1);+        assert!(info("(kicad_symbol_lib)").is_err());+    }+}+
rust/crates/kicad-core/src/ipc.rs+4
@@ -43,6 +43,10 @@ pub const MAX_WALK: usize = 100_000;  mod silk_graphics; pub use silk_graphics::silk_graphics_batch;+mod route_edit;+pub use route_edit::{apply_track_change, copy_routing, modify_track, route_diff_pair};+mod board_live;+pub use board_live::*;  type RResult<T> = Result<T, RoutingError>; 
rust/crates/kicad-core/src/ipc/board_live.rsadded+648
@@ -0,0 +1,648 @@+//! Live board and footprint edits: every `board_edit` change committed into the open PCB+//! editor as ONE native Undo step, with the same guards as the routing verbs (exact board,+//! `expectedRevision`, KiCad's DRC on a snapshot when copper changes, `dryRun`, a verified+//! read-back), plus the editor selection, zone refill and save.+//!+//! A change is committed as: delete the old top-level items by id, then create the new+//! blocks through `ParseAndCreateItemsFromString` (KiCad parses our text with its own+//! parser). Created blocks carry fresh UUIDs so they never collide with an item the same+//! commit deletes. A footprint flip uses KiCad's own `FlipItems` (10.0.6+) when the editor+//! answers it, and the client-side mirror otherwise.++use super::*;+use crate::board_edit::{self, Change};+use crate::placement::{parse_fields, varint_value, wire_message, wire_varint};++fn requires_revision(args: &Value) -> RResult<()> {+    if args.get("expectedRevision").and_then(Value::as_str).filter(|s| !s.is_empty()).is_none() {+        return Err(RoutingError::new("revision_required", "Pass expectedRevision: the revision a read of this board returned (kicad_routing_state, kicad_placement_state or a previous edit's reply)").with("mutated", json!(false)));+    }+    Ok(())+}++fn post_save(s: &Session, args: &Value, out: &mut Value) {+    if args.get("save") == Some(&json!(true)) {+        match s.client.save_document() {+            Ok(()) => out["saved"] = json!(true),+            Err(e) => out["postCommitSaveError"] = json!(e.to_string()),+        }+    }+}++fn drc_gate(ctx: &Ctx, s: &Session, text: &str, change: &Change, args: &Value) -> RResult<Value> {+    if !change.copper || args.get("validate") == Some(&json!(false)) {+        return Ok(json!({"ran": false, "why": if change.copper { "validate:false" } else { "no copper changes" }}));+    }+    let before = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), text)?;+    let after = drc_snapshot(ctx.kicad_cli.as_deref(), &s.source_path(), &change.candidate)?;+    let added = new_errors(&before, &after)?;+    if !added.is_empty() {+        return Err(RoutingError::new("drc_rejected", "The edit adds DRC errors (KiCad's DRC on a snapshot of the live board with the edit applied); nothing was changed")+            .with("violations", json!(added))+            .with("mutated", json!(false))+            .with("_hint", json!("Each violation names the rule and the items. Move the part or pick another spot (a dryRun is cheap), or pass validate:false to commit anyway and fix it afterwards.")));+    }+    Ok(after)+}++/// The live counterpart of a file edit: build the change on the editor's own text, check+/// it, commit it as one Undo step, read it back.+pub fn commit_change(ctx: &Ctx, args: &Value, build: impl FnOnce(&str) -> RResult<Change>) -> RResult<Value> {+    let s = connect(ctx, args)?;+    requires_revision(args)?;+    let (text, _board, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let change = build(&text)?;+    commit_built(ctx, args, &s, &text, &revision, change, None)+}++/// Commit an already-built change on session `s` (snapshot `text`, `revision`).+/// `native` optionally commits it another way (FlipItems) and answers how many items it+/// handled; None from it falls back to the delete-and-create path.+fn commit_built(ctx: &Ctx, args: &Value, s: &Session, text: &str, revision: &str, change: Change, native: Option<&dyn Fn() -> Result<Option<Value>, IpcFailure>>) -> RResult<Value> {+    let mut out = json!({"success": true, "source": "live-editor", "boardPath": s.file_path, "kicadVersion": s.version.full_version});+    merge(&mut out, &change.report);+    if change.remove.is_empty() && change.create.is_empty() {+        out["mutated"] = json!(false);+        out["undoSteps"] = json!(0);+        out["revision"] = json!(revision);+        out["_hint"] = json!("Nothing to change: the board already matches.");+        return Ok(out);+    }+    let drc = drc_gate(ctx, s, text, &change, args)?;+    out["drc"] = drc;+    if args.get("dryRun") == Some(&json!(true)) {+        out["dryRun"] = json!(true);+        out["mutated"] = json!(false);+        out["undoSteps"] = json!(0);+        out["revision"] = json!(revision);+        out["wouldRemoveIds"] = json!(change.remove);+        out["wouldCreate"] = json!(change.create.len());+        out["_hint"] = json!("Checked without editing (drc is KiCad's verdict on the board with this edit). Submit with dryRun:false and the same expectedRevision to commit it as one Undo step.");+        return Ok(out);+    }+    // DRC ran outside KiCad: an edit in between invalidates this change.+    check_revision(args, &s.revision()?)?;+    let before_ids: HashSet<String> = board_edit::top_level_ids(text).into_iter().collect();+    let wrapped = board_edit::create_text(text, &change.create);+    let want = change.create.len();+    let remove = change.remove.clone();+    let mut how = "delete-and-create";+    let mut native_detail = Value::Null;+    transaction(s, &change.label, || {+        if let Some(f) = native {+            if let Some(v) = f()? {+                how = "native";+                native_detail = v;+                return Ok(());+            }+        }+        if !remove.is_empty() {+            s.client.delete_items(remove.clone())?;+        }+        if want > 0 {+            let created = s.client.parse_and_create_items_from_string(wrapped.clone())?;+            if created.len() != want {+                return Err(IpcFailure::from(format!("KiCad created {} of the {want} requested items; rollback requested", created.len())));+            }+        }+        Ok(())+    })+    .map_err(|e| {+        if e.message.contains("AS_UNIMPLEMENTED") || e.message.contains("AS_UNHANDLED") {+            e.with("_hint", json!("This KiCad does not serve the IPC call this edit needs (ParseAndCreateItemsFromString). Nothing was changed. Save and close the board, then call again: the bridge edits the closed file instead (a backup is kept)."))+        } else {+            e+        }+    })?;+    out["mutated"] = json!(true);+    out["undoSteps"] = json!(1);+    out["undoLabel"] = json!(change.label);+    out["commit"] = json!(how);+    if !native_detail.is_null() {+        out["native"] = native_detail;+    }+    out["saved"] = json!(false);+    out["_hint"] = json!("Committed live as one KiCad Undo step (Edit > Undo reverts it). verified is the read-back of the live board. Use the returned revision for the next edit. The file is saved only with save:true or kicad_save_board.");+    match s.snapshot() {+        Ok((after_text, after, rev)) => {+            out["revision"] = json!(rev);+            let after_ids: Vec<String> = board_edit::top_level_ids(&after_text);+            let created: Vec<&String> = after_ids.iter().filter(|i| !before_ids.contains(*i)).collect();+            let lingering: Vec<&String> = change.remove.iter().filter(|r| after_ids.contains(r)).collect();+            let (refs_ok, rows) = board_edit::verify(&after, &change.expect);+            let counts_ok = how == "native" || (created.len() == want && lingering.is_empty());+            out["createdIds"] = json!(created);+            out["removedIds"] = json!(change.remove);+            out["readback"] = json!(rows);+            out["verified"] = json!(refs_ok && counts_ok);+            if !(refs_ok && counts_ok) {+                out["postCommitError"] = json!(format!("the live board does not show the edit as planned ({} created of {want}, {} removed item(s) still present, footprint checks {})", created.len(), lingering.len(), if refs_ok { "ok" } else { "failed" }));+                out["_hint"] = json!("Committed, but the read-back differs from the plan: inspect the board (kicad_placement_state), use Edit > Undo in KiCad if it is wrong, and report the reply. Never replay the edit blindly.");+            }+        }+        Err(e) => out["postCommitError"] = json!(e.message),+    }+    post_save(s, args, &mut out);+    Ok(out)+}+++// ---------------------------------------------------------------------------+// FlipItems (KiCad 10.0.6+), on the wire+// ---------------------------------------------------------------------------++pub const FLIP_ITEMS_TYPE: &str = "type.googleapis.com/kiapi.board.commands.FlipItems";++/// `kiapi.common.types.DocumentSpecifier` for a board: type DOCTYPE_PCB (3), board_filename+/// (4), project {name 1, path 2} (5).+pub fn document_bytes(board_filename: &str, project_name: Option<&str>, project_path: Option<&str>) -> Vec<u8> {+    let mut d = Vec::new();+    wire_varint(&mut d, 1, 3);+    wire_message(&mut d, 4, board_filename.as_bytes());+    let mut p = Vec::new();+    if let Some(n) = project_name.filter(|s| !s.is_empty()) {+        wire_message(&mut p, 1, n.as_bytes());+    }+    if let Some(pp) = project_path.filter(|s| !s.is_empty()) {+        wire_message(&mut p, 2, pp.as_bytes());+    }+    if !p.is_empty() {+        wire_message(&mut d, 5, &p);+    }+    d+}++/// `kiapi.board.commands.FlipItems {header 1 {document 1}, items 2 (KIID {value 1}), direction 3}`;+/// direction BFD_LEFT_RIGHT 1, BFD_TOP_BOTTOM 2.+pub fn flip_items_bytes(document: &[u8], ids: &[String], left_right: bool) -> Vec<u8> {+    let mut header = Vec::new();+    wire_message(&mut header, 1, document);+    let mut out = Vec::new();+    wire_message(&mut out, 1, &header);+    for id in ids {+        let mut k = Vec::new();+        wire_message(&mut k, 1, id.as_bytes());+        wire_message(&mut out, 2, &k);+    }+    wire_varint(&mut out, 3, if left_right { 1 } else { 2 });+    out+}++/// `FlipItemsResponse {header 1, status 2, flipped_items 3 {status 1 {code 1}, item 2}}`:+/// (request status, per-item status codes). ISC_OK is 1, IRS_OK is 1.+pub fn decode_flip_response(b: &[u8]) -> Result<(u64, Vec<u64>), String> {+    let mut status = 0;+    let mut codes = Vec::new();+    for f in parse_fields(b)? {+        match (f.number, f.wire) {+            (2, 0) => status = varint_value(f.data)?,+            (3, 2) => {+                let mut code = 0;+                for g in parse_fields(f.data)? {+                    if g.number == 1 && g.wire == 2 {+                        for h in parse_fields(g.data)? {+                            if h.number == 1 && h.wire == 0 {+                                code = varint_value(h.data)?;+                            }+                        }+                    }+                }+                codes.push(code);+            }+            _ => {}+        }+    }+    Ok((status, codes))+}++fn board_document(s: &Session) -> Result<Vec<u8>, KiCadError> {+    let docs = s.client.get_open_documents(DocumentType::Pcb)?;+    let wanted = normalize_existing(&s.file_path);+    let d = docs.iter().find(|d| normalize_existing(&document_path(d)) == wanted).or(docs.first()).ok_or(KiCadError::BoardNotOpen)?;+    Ok(document_bytes(+        d.board_filename.as_deref().unwrap_or(""),+        d.project.name.as_deref(),+        d.project.path.as_ref().map(|p| p.to_string_lossy().to_string()).as_deref(),+    ))+}++/// `kicad_flip_footprint`, live: KiCad's FlipItems when it answers (10.0.6+), else the+/// client-side mirror committed as delete-and-create; KiCad's DRC first either way.+pub fn flip_footprint(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    requires_revision(args)?;+    let (text, _board, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let mut change = board_edit::flip_footprints(&text, args)?;+    let left_right = change.report["direction"] == json!("left_right");+    let native_ok = (s.version.major, s.version.minor, s.version.patch) >= (10, 0, 6) && args.get("native") != Some(&json!(false));+    let ids = change.remove.clone();+    // The native flip's orientation is KiCad's own: check side and anchor, not the angle.+    if native_ok {+        for x in change.expect.iter_mut() {+            x.pose = None;+        }+    }+    let attempted = std::cell::RefCell::new(Value::Null);+    let native = || -> Result<Option<Value>, IpcFailure> {+        if !native_ok {+            return Ok(None);+        }+        let doc = board_document(&s)?;+        let cmd = prost_types::Any { type_url: FLIP_ITEMS_TYPE.into(), value: flip_items_bytes(&doc, &ids, left_right) };+        match s.client.send_raw_command(cmd) {+            Ok(reply) => {+                let (status, codes) = decode_flip_response(&reply.value).map_err(IpcFailure::from)?;+                let ok = codes.iter().filter(|c| **c == 1).count();+                if status != 1 || ok != ids.len() {+                    return Err(IpcFailure::from(format!("FlipItems flipped {ok} of {} footprints (request status {status}, item codes {codes:?})", ids.len())));+                }+                Ok(Some(json!({"command": "FlipItems", "flipped": ok})))+            }+            Err(KiCadError::ApiStatus { code, message }) if code == "AS_UNHANDLED" || code == "AS_UNIMPLEMENTED" || code == "AS_BAD_REQUEST" => {+                *attempted.borrow_mut() = json!({"command": "FlipItems", "apiStatus": code, "message": message});+                Ok(None)+            }+            Err(e) => Err(IpcFailure::from(e)),+        }+    };+    let mut out = commit_built(ctx, args, &s, &text, &revision, change, Some(&native))?;+    if !attempted.borrow().is_null() {+        out["nativeAttempt"] = attempted.borrow().clone();+    }+    Ok(out)+}++// ---------------------------------------------------------------------------+// kicad_edit_footprint, live: the footprint proto updated in place+// ---------------------------------------------------------------------------++/// Only these keys can go through the proto update (fields need the text path).+const PROTO_KEYS: &[&str] = &["value", "newReference", "reference", "attributes", "locked", "excludeFromPosFiles", "excludeFromBom", "dnp", "boardOnly", "allowMissingCourtyard", "allowSoldermaskBridges"];++macro_rules! set_field_text {+    ($field:expr, $v:expr) => {+        match $field.as_mut().and_then(|f| f.text.as_mut()).and_then(|t| t.text.as_mut()) {+            Some(t) => {+                t.text = $v.to_string();+                true+            }+            None => false,+        }+    };+}++macro_rules! apply_attr_flags {+    ($a:expr, $flags:expr) => {{+        let mut res: RResult<()> = Ok(());+        for (k, v) in $flags.iter() {+            let Some(b) = v.as_bool() else {+                res = Err(RoutingError::new("invalid_args", format!("{k} must be true or false")).with("mutated", json!(false)));+                break;+            };+            match k.as_str() {+                "excludeFromPosFiles" => $a.exclude_from_position_files = b,+                "excludeFromBom" => $a.exclude_from_bill_of_materials = b,+                "dnp" => $a.do_not_populate = b,+                "allowMissingCourtyard" => $a.exempt_from_courtyard_requirement = b,+                "allowSoldermaskBridges" => $a.allow_soldermask_bridges = b,+                "boardOnly" => $a.not_in_schematic = b,+                other => {+                    res = Err(RoutingError::new("invalid_args", format!("unknown attribute {other}")).with("mutated", json!(false)));+                    break;+                }+            }+        }+        res+    }};+}++/// Apply value, reference, attribute flags and lock to a FootprintInstance proto (the+/// attribute `type` and custom fields take the delete-and-create path instead).+pub fn edit_footprint_proto(item: &mut EditablePcbItem, args: &Value) -> RResult<()> {+    let EditablePcbItem::Footprint(fp) = item else {+        return Err(RoutingError::new("footprint_not_found", "the item is not a footprint").with("mutated", json!(false)));+    };+    let p = fp.proto_mut();+    if let Some(v) = args.get("value").and_then(Value::as_str) {+        if !set_field_text!(p.value_field, v) {+            return Err(RoutingError::new("ipc_error", "KiCad sent the footprint without a value field").with("mutated", json!(false)));+        }+    }+    if let Some(v) = args.get("newReference").or_else(|| args.get("reference")).and_then(Value::as_str) {+        if !set_field_text!(p.reference_field, v) {+            return Err(RoutingError::new("ipc_error", "KiCad sent the footprint without a reference field").with("mutated", json!(false)));+        }+    }+    let mut flags = args.get("attributes").and_then(Value::as_object).cloned().unwrap_or_default();+    for (k, _) in crate::fp_text::ATTR_FLAGS {+        if let Some(v) = args.get(*k) {+            flags.insert(k.to_string(), v.clone());+        }+    }+    if !flags.is_empty() {+        if p.attributes.is_none() {+            p.attributes = Some(Default::default());+        }+        if let Some(a) = p.attributes.as_mut() {+            apply_attr_flags!(a, flags)?;+        }+        if let Some(a) = p.definition.as_mut().and_then(|d| d.attributes.as_mut()) {+            apply_attr_flags!(a, flags)?;+        }+    }+    if let Some(l) = args.get("locked").and_then(Value::as_bool) {+        p.locked = if l { 2 } else { 1 };+    }+    Ok(())+}++/// `kicad_edit_footprint`, live. Value, reference, attribute flags and lock go through the+/// footprint proto (UpdateItems, the footprint keeps its UUID); field edits go through the+/// delete-and-create path.+pub fn edit_footprint(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let proto_only = args.as_object().map(|o| o.keys().all(|k| PROTO_KEYS.contains(&k.as_str()) || !is_change_key(k))).unwrap_or(false)+        && args.get("attributes").and_then(Value::as_object).map(|a| !a.contains_key("type")).unwrap_or(true);+    if !proto_only {+        return commit_change(ctx, args, |text| board_edit::edit_footprints(text, args));+    }+    let s = connect(ctx, args)?;+    requires_revision(args)?;+    let (text, _board, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let change = board_edit::edit_footprints(&text, args)?;+    let mut out = json!({"success": true, "source": "live-editor", "boardPath": s.file_path});+    merge(&mut out, &change.report);+    if args.get("dryRun") == Some(&json!(true)) {+        out["dryRun"] = json!(true);+        out["mutated"] = json!(false);+        out["undoSteps"] = json!(0);+        out["revision"] = json!(revision);+        return Ok(out);+    }+    check_revision(args, &s.revision()?)?;+    let mut items = s.client.get_editable_items_by_id(change.remove.clone()).map_err(map_err)?;+    if items.len() != change.remove.len() {+        return Err(RoutingError::new("footprint_not_found", "The editor no longer holds every footprint; read the board again").with("mutated", json!(false)));+    }+    for it in items.iter_mut() {+        edit_footprint_proto(it, args)?;+    }+    let n_items = items.len();+    transaction(&s, &change.label, || {+        let updated = s.client.update_editable_items(items)?;+        if updated.len() != n_items {+            return Err(IpcFailure::from("KiCad did not update every footprint".to_string()));+        }+        Ok(())+    })?;+    out["mutated"] = json!(true);+    out["undoSteps"] = json!(1);+    out["undoLabel"] = json!(change.label);+    out["commit"] = json!("update-items");+    out["saved"] = json!(false);+    // read back: every edited footprint shows the planned fields, attributes and lock+    match s.snapshot() {+        Ok((after_text, _after, rev)) => {+            out["revision"] = json!(rev);+            let root = crate::sxedit::parse(&after_text).map_err(|e| RoutingError::new("board_unparsable", e))?;+            let mut ok = true;+            let mut rows = Vec::new();+            for row in change.report["edited"].as_array().into_iter().flatten() {+                let want = &row["after"];+                let got = root.kids_tagged("footprint").find(|f| f.uuid() == want["uuid"].as_str()).map(board_edit::footprint_info);+                let good = got.as_ref().map(|g| g["ref"] == want["ref"] && g["fields"]["Value"] == want["fields"]["Value"] && g["attributes"] == want["attributes"] && g["locked"] == want["locked"]).unwrap_or(false);+                ok &= good;+                rows.push(json!({"ref": want["ref"], "ok": good, "live": got}));+            }+            out["readback"] = json!(rows);+            out["verified"] = json!(ok);+            if !ok {+                out["postCommitError"] = json!("the live footprint does not show every planned change; inspect it and use Edit > Undo if it is wrong");+            }+        }+        Err(e) => out["postCommitError"] = json!(e.message),+    }+    out["_hint"] = json!("Edited live as one KiCad Undo step; the footprint keeps its UUID. Use the returned revision for the next edit.");+    post_save(&s, args, &mut out);+    Ok(out)+}++fn is_change_key(k: &str) -> bool {+    matches!(k, "fields" | "value" | "newReference" | "reference" | "attributes" | "locked") || crate::fp_text::ATTR_FLAGS.iter().any(|(f, _)| *f == k)+}++// ---------------------------------------------------------------------------+// Selection, refill, save+// ---------------------------------------------------------------------------++fn item_row(it: &PcbItem) -> Value {+    let net = |n: &Option<BoardNet>| n.as_ref().map(|n| json!(n.name)).unwrap_or(Value::Null);+    let mm = |v: &Option<Vector2Nm>| v.map(|p| json!([p.x_nm as f64 / 1e6, p.y_nm as f64 / 1e6])).unwrap_or(Value::Null);+    match it {+        PcbItem::Footprint(f) => json!({"type": "footprint", "id": f.id, "ref": f.reference, "value": f.value, "layer": f.layer.name, "at": mm(&f.position_nm), "rotation": f.orientation_deg, "locked": f.locked.is_locked()}),+        PcbItem::Pad(p) => json!({"type": "pad", "id": p.id, "number": p.number, "net": net(&p.net), "at": mm(&p.position_nm)}),+        PcbItem::Track(t) => json!({"type": "track", "id": t.id, "net": net(&t.net), "layer": t.layer.name, "start": mm(&t.start_nm), "end": mm(&t.end_nm), "widthMm": t.width_nm.map(|w| w as f64 / 1e6)}),+        PcbItem::Arc(a) => json!({"type": "arc", "id": a.id, "net": net(&a.net), "layer": a.layer.name}),+        PcbItem::Via(v) => json!({"type": "via", "id": v.id, "net": net(&v.net), "at": mm(&v.position_nm)}),+        PcbItem::Zone(z) => json!({"type": "zone", "id": z.id, "name": z.name, "layers": z.layers.iter().map(|l| l.name.clone()).collect::<Vec<_>>()}),+        PcbItem::BoardGraphicShape(g) => json!({"type": "graphic", "id": g.id, "layer": g.layer.name, "shape": g.geometry_kind, "net": net(&g.net)}),+        PcbItem::BoardText(t) => json!({"type": "text", "id": t.id, "layer": t.layer.name, "text": t.text}),+        PcbItem::BoardTextBox(t) => json!({"type": "textbox", "id": t.id}),+        PcbItem::Field(_) => json!({"type": "field"}),+        PcbItem::Dimension(d) => json!({"type": "dimension", "id": d.id}),+        PcbItem::ReferenceImage(r) => json!({"type": "image", "id": r.id}),+        PcbItem::Barcode(b) => json!({"type": "barcode", "id": b.id}),+        PcbItem::Group(g) => json!({"type": "group", "id": g.id}),+        PcbItem::Unknown(_) => json!({"type": "unknown"}),+    }+}++fn selection_reply(items: &[PcbItem]) -> Value {+    let rows: Vec<Value> = items.iter().map(item_row).collect();+    let mut by: std::collections::BTreeMap<String, usize> = Default::default();+    for r in &rows {+        *by.entry(r["type"].as_str().unwrap_or("unknown").to_string()).or_insert(0) += 1;+    }+    let mut refs: Vec<String> = rows.iter().filter_map(|r| r["ref"].as_str().map(str::to_string)).collect();+    refs.sort_by_key(|r| placement::ref_key(r));+    let mut nets: Vec<String> = rows.iter().filter_map(|r| r["net"].as_str().map(str::to_string)).filter(|n| !n.is_empty()).collect();+    nets.sort();+    nets.dedup();+    json!({"count": rows.len(), "byType": by, "references": refs, "nets": nets, "items": rows})+}++/// `kicad_get_selection`: what is selected in the PCB editor.+pub fn get_selection(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let items = s.client.get_selection(Vec::new()).map_err(map_err)?;+    let mut out = json!({"success": true, "mutated": false, "source": "live-editor", "boardPath": s.file_path,+        "_hint": "The editor's selection right now (the user's or one set with kicad_select_items / kicad_select_net). Footprints carry ref; copper carries net. Selection is not an edit: no revision change."});+    merge(&mut out, &selection_reply(&items));+    Ok(out)+}++/// `kicad_select_items`: select by item ids and/or footprint references; `mode` replace+/// (default), add or remove.+pub fn select_items(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let mut ids: Vec<String> = args.get("itemIds").or_else(|| args.get("ids")).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();+    let refs: Vec<String> = args.get("refs").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();+    let mut missing = Vec::new();+    if !refs.is_empty() {+        let (_text, board, _rev) = s.snapshot()?;+        for r in &refs {+            match board.footprints.iter().find(|f| f.reference.eq_ignore_ascii_case(r)).and_then(|f| f.uuid.clone()) {+                Some(u) => ids.push(u),+                None => missing.push(r.clone()),+            }+        }+    }+    if !missing.is_empty() {+        return Err(RoutingError::new("unknown_footprint", format!("No footprint with reference {}", missing.join(", "))).with("missing", json!(missing)));+    }+    let mode = args.get("mode").and_then(Value::as_str).unwrap_or("replace");+    if ids.is_empty() && mode != "replace" {+        return Err(RoutingError::new("missing_arg", "Pass itemIds and/or refs"));+    }+    if ids.len() > 10_000 {+        return Err(RoutingError::new("invalid_args", "At most 10000 items"));+    }+    match mode {+        "replace" => {+            s.client.clear_selection().map_err(map_err)?;+            if !ids.is_empty() {+                s.client.add_to_selection(ids.clone()).map_err(map_err)?;+            }+        }+        "add" => {+            s.client.add_to_selection(ids.clone()).map_err(map_err)?;+        }+        "remove" => {+            s.client.remove_from_selection(ids.clone()).map_err(map_err)?;+        }+        other => return Err(RoutingError::new("invalid_args", format!("mode must be replace, add or remove, not '{other}'"))),+    }+    let now = s.client.get_selection(Vec::new()).map_err(map_err)?;+    let selected: HashSet<String> = now.iter().filter_map(|i| item_id(i).map(str::to_string)).collect();+    let not_selected: Vec<&String> = if mode == "remove" { ids.iter().filter(|i| selected.contains(*i)).collect() } else { ids.iter().filter(|i| !selected.contains(*i)).collect() };+    let mut out = json!({"success": true, "mutated": false, "source": "live-editor", "mode": mode, "requested": ids.len(), "verified": not_selected.is_empty(),+        "_hint": "The selection is set (kicad_get_selection reads it back; Zoom to Selected frames it). Selection is not an edit: nothing to undo, the revision is unchanged."});+    if !not_selected.is_empty() {+        out["notApplied"] = json!(not_selected);+        out["_hint"] = json!("Some ids did not take: KiCad selects only items that exist on the board (and not children of a locked or hidden layer). notApplied lists them.");+    }+    merge(&mut out, &selection_reply(&now));+    Ok(out)+}++/// `kicad_refill_zones`: refill every zone (or the listed ones when KiCad accepts a list),+/// wait for the asynchronous fill to settle, and report the filled copper.+pub fn refill_zones(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let ids: Vec<String> = args.get("zoneIds").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();+    let before = s.revision().ok();+    let mut note = Value::Null;+    let mut all = ids.is_empty();+    if !ids.is_empty() {+        match s.client.refill_zones(ids.clone()) {+            Ok(()) => {}+            Err(KiCadError::ApiStatus { code, message }) if code == "AS_UNIMPLEMENTED" || code == "AS_BAD_REQUEST" => {+                note = json!(format!("KiCad answered {code} ({message}) to a zone list; every zone was refilled instead"));+                s.client.refill_zones(Vec::new()).map_err(map_err)?;+                all = true;+            }+            Err(e) => return Err(map_err(e)),+        }+    } else {+        s.client.refill_zones(Vec::new()).map_err(map_err)?;+    }+    let (_, settled) = settle_revision(before.as_deref(), || s.revision().map_err(|e| e.message));+    let (_text, data, revision) = s.snapshot()?;+    let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?;+    let mut read = Vec::with_capacity(raws.len());+    for a in &raws {+        read.push(zones::decode_zone(&a.value).map_err(|e| RoutingError::new("zone_undecodable", e))?);+    }+    let mut out = json!({"success": true, "mutated": true, "source": "live-editor", "boardPath": s.file_path, "refilledAll": all, "requestedZoneIds": ids,+        "revision": revision, "revisionSettled": settled, "saved": false,+        "_hint": if settled { "Zones refilled (one KiCad Undo step of its own). filledAreaByLayerMm2 is the poured copper per layer. Use this revision for the next edit; the file is saved only with save:true or kicad_save_board." } else { "Refill requested but KiCad was still filling after 12 s; the revision may already be stale. Call kicad_zone_state (refill:false) for a settled revision before the next edit." }});+    if !note.is_null() {+        out["note"] = note;+    }+    merge(&mut out, &zones::state_json(&read, &data));+    post_save(&s, args, &mut out);+    Ok(out)+}++/// `kicad_save_board`: save the open board through the IPC API and confirm the file.+pub fn save_board(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let path = s.source_path();+    let mtime = || std::fs::metadata(&path).and_then(|m| m.modified()).ok();+    let before = mtime();+    let live = s.client.get_board_as_string().map_err(map_err)?;+    s.client.save_document().map_err(map_err)?;+    let after = mtime();+    let file = std::fs::read_to_string(&path).ok();+    let model = |t: &str| crate::board_model::revision_of_text(t).ok();+    let matches = file.as_deref().and_then(model).zip(model(&live)).map(|(a, b)| a == b);+    Ok(json!({+        "success": true, "mutated": true, "saved": true, "source": "live-editor", "boardPath": s.file_path,+        "fileWritten": before != after || before.is_none(), "fileMatchesEditor": matches, "revision": revision_of(&live),+        "_hint": "Saved by KiCad itself (File > Save). fileMatchesEditor compares the copper, parts and outline of the file on disk with the editor's board; null means the file could not be read from here (a different machine path).",+    }))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn flip_items_is_encoded_the_way_kicad_10_0_6_reads_it() {+        let doc = document_bytes("b.kicad_pcb", Some("b"), Some("C:/p"));+        let bytes = flip_items_bytes(&doc, &["id-1".into(), "id-2".into()], true);+        let fields = parse_fields(&bytes).unwrap();+        assert_eq!(fields.iter().map(|f| f.number).collect::<Vec<_>>(), vec![1, 2, 2, 3]);+        assert_eq!(varint_value(fields[3].data).unwrap(), 1);+        let header = parse_fields(fields[0].data).unwrap();+        let docf = parse_fields(header[0].data).unwrap();+        assert_eq!(varint_value(docf[0].data).unwrap(), 3);+        assert_eq!(docf[1].data, b"b.kicad_pcb");+        let kiid = parse_fields(fields[1].data).unwrap();+        assert_eq!(kiid[0].data, b"id-1");+        // a response: IRS_OK and two ISC_OK rows+        let mut resp = Vec::new();+        wire_varint(&mut resp, 2, 1);+        for _ in 0..2 {+            let mut code = Vec::new();+            wire_varint(&mut code, 1, 1);+            let mut st = Vec::new();+            wire_message(&mut st, 1, &code);+            wire_message(&mut resp, 3, &st);+        }+        assert_eq!(decode_flip_response(&resp).unwrap(), (1, vec![1, 1]));+    }++    #[test]+    fn the_footprint_proto_takes_value_attributes_and_lock() {+        let mut item = EditablePcbItem::Footprint(kicad_ipc_rs::FootprintItem::from_proto(Default::default()));+        if let EditablePcbItem::Footprint(fp) = &mut item {+            let p = fp.proto_mut();+            p.value_field = Some(Default::default());+            let f = p.value_field.as_mut().unwrap();+            f.text = Some(Default::default());+            f.text.as_mut().unwrap().text = Some(Default::default());+        }+        edit_footprint_proto(&mut item, &json!({"value": "10k", "dnp": true, "excludeFromBom": true, "locked": true})).unwrap();+        let EditablePcbItem::Footprint(fp) = &mut item else { unreachable!() };+        let p = fp.proto_mut();+        assert_eq!(p.value_field.as_ref().unwrap().text.as_ref().unwrap().text.as_ref().unwrap().text, "10k");+        let a = p.attributes.as_ref().unwrap();+        assert!(a.do_not_populate && a.exclude_from_bill_of_materials && !a.exclude_from_position_files);+        assert_eq!(p.locked, 2);+        // no reference field: refused, not silently ignored+        assert_eq!(edit_footprint_proto(&mut item, &json!({"newReference": "R9"})).unwrap_err().code, "ipc_error");+    }+}
rust/crates/kicad-core/src/ipc/route_edit.rsadded+211
@@ -0,0 +1,211 @@+//! Live commits for the copper edit verbs planned in `crate::route_tools`:+//! `kicad_modify_track`, `kicad_route_diff_pair` and `kicad_copy_routing`. Each one reads+//! the live board, checks `expectedRevision`, plans, runs KiCad's DRC on a snapshot with+//! and without the change (refusing new errors), re-checks the revision and lands the+//! whole edit as ONE native Undo step.++use super::*;+use crate::route_tools::{self, TrackChange};++fn preflight(ctx: &Ctx, s: &Session, before_text: &str, after_text: &str, args: &Value, what: &str) -> RResult<Value> {+    if args.get("validate") == Some(&json!(false)) {+        return Ok(json!({"skipped": true, "why": "validate:false"}));+    }+    let source = s.source_path();+    let before = drc_snapshot(ctx.kicad_cli.as_deref(), &source, before_text)?;+    let after = drc_snapshot(ctx.kicad_cli.as_deref(), &source, after_text)?;+    let added = new_errors(&before, &after)?;+    if !added.is_empty() {+        return Err(RoutingError::new("drc_rejected", format!("{what} adds DRC errors; no live copper was changed")).with("violations", json!(added)).with("mutated", json!(false)));+    }+    Ok(after)+}++/// The project file beside the live board, for netclass defaults.+fn project_of(s: &Session) -> Option<Value> {+    let pro = s.source_path().with_extension("kicad_pro");+    crate::project_rules::read_json(&pro).ok()+}++/// Apply one planned change to the editor's own track proto: width, layer and endpoints;+/// every other field (net, locked state, id) stays as KiCad sent it.+pub fn apply_track_change(track: &mut TrackItem, change: &TrackChange) -> RResult<()> {+    let layer = BoardLayerInfo::id_from_name(&change.after.layer).ok_or_else(|| RoutingError::new("unknown_layer", format!("KiCad has no layer named {}", change.after.layer)))?;+    track.set_start_nm(vector(change.after.start));+    track.set_end_nm(vector(change.after.end));+    track.set_layer_id(layer);+    let p = track.proto_mut();+    p.width = Some(Default::default());+    if let Some(w) = p.width.as_mut() {+        w.value_nm = nm(change.after.width);+    }+    Ok(())+}++/// `kicad_modify_track`: change width, layer or endpoints of tracks by id.+pub fn modify_track(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text, data, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let (changes, candidate) = route_tools::plan_track_changes(&data, &text, args)?;+    let check = preflight(ctx, &s, &text, &candidate, args, "The track change")?;+    if args.get("dryRun") == Some(&json!(true)) {+        return Ok(json!({"success": true, "dryRun": true, "mutated": false, "revision": revision, "changes": changes, "drc": check, "source": "live-editor",+            "_hint": "Checked without editing. Submit with dryRun:false and the same revision to change them as one Undo step."}));+    }+    check_revision(args, &s.revision()?)?;+    let ids: Vec<String> = changes.iter().map(|c| c.id.clone()).collect();+    let mut items = s.client.get_editable_items_by_id(ids.clone()).map_err(map_err)?;+    if items.len() != ids.len() {+        return Err(RoutingError::new("unknown_track", "KiCad did not return every track by id; nothing changed").with("mutated", json!(false)));+    }+    for item in items.iter_mut() {+        let EditablePcbItem::Track(t) = item else {+            return Err(RoutingError::new("unknown_track", "an id is not a straight track in the live editor; nothing changed").with("mutated", json!(false)));+        };+        let id = t.id().unwrap_or_default().to_string();+        let change = changes.iter().find(|c| c.id == id).ok_or_else(|| RoutingError::new("unknown_track", format!("unexpected item {id}")))?;+        apply_track_change(t, change)?;+    }+    let count = items.len();+    transaction(&s, &format!("Adom modify {} track(s)", count), || {+        let updated = s.client.update_editable_items(items)?;+        if updated.len() != count {+            return Err(IpcFailure::from("KiCad did not update every requested track".to_string()));+        }+        Ok(())+    })?;+    let mut out = json!({"success": true, "mutated": true, "source": "live-editor", "changes": changes, "undoSteps": 1, "drc": check, "saved": false,+        "_hint": "Tracks changed live as one KiCad Undo step. Moving an endpoint does not drag the tracks joined to it: read kicad_routing_validate for dangling ends."});+    match s.snapshot() {+        Ok((_, after, rev)) => {+            out["revision"] = json!(rev);+            let wrong: Vec<&str> = changes.iter().filter(|c| {+                after.segments.iter().find(|x| x.uuid.as_deref() == Some(c.id.as_str())).map(|x| {+                    let close = |a: f64, b: f64| (a - b).abs() < 1e-5;+                    !(x.width.map(|w| close(w, c.after.width)).unwrap_or(false) && x.layer.as_deref() == Some(c.after.layer.as_str())+                        && x.start.as_ref().map(|p| close(p.x, c.after.start[0]) && close(p.y, c.after.start[1])).unwrap_or(false)+                        && x.end.as_ref().map(|p| close(p.x, c.after.end[0]) && close(p.y, c.after.end[1])).unwrap_or(false))+                }).unwrap_or(true)+            }).map(|c| c.id.as_str()).collect();+            out["verified"] = json!(wrong.is_empty());+            if !wrong.is_empty() {+                out["postCommitError"] = json!(format!("read-back differs for {wrong:?}; inspect the live board, do not resubmit blindly"));+            }+            if args.get("save") == Some(&json!(true)) {+                match s.client.save_document() {+                    Ok(()) => out["saved"] = json!(true),+                    Err(e) => out["postCommitError"] = json!(e.to_string()),+                }+            }+        }+        Err(e) => out["postCommitError"] = json!(e.message),+    }+    Ok(out)+}++/// Commit several planned nets as ONE undo step.+fn commit_plans(s: &Session, plans: &[&Plan], message: &str) -> RResult<Vec<String>> {+    let nets = s.client.get_nets().map_err(map_err)?;+    let mut items = Vec::new();+    for p in plans {+        let Some(net) = nets.iter().find(|n| n.name == p.net_name) else {+            return Err(RoutingError::new("unknown_net", format!("The live board has no net named '{}'", p.net_name)).with("mutated", json!(false)));+        };+        items.extend(build_items(p, net)?);+    }+    let count = items.len();+    let created = transaction(s, message, || {+        let created = s.client.create_editable_items(items, None)?;+        if created.len() != count || created.iter().any(|i| i.id().map(str::is_empty).unwrap_or(true)) {+            return Err(IpcFailure::from("KiCad did not create every requested copper item".to_string()));+        }+        Ok(created)+    })?;+    Ok(created.iter().filter_map(|i| i.id().map(str::to_string)).collect())+}++fn finish(s: &Session, args: &Value, mut out: Value) -> Value {+    match s.revision() {+        Ok(r) => {+            out["revision"] = json!(r);+            if args.get("save") == Some(&json!(true)) {+                match s.client.save_document() {+                    Ok(()) => out["saved"] = json!(true),+                    Err(e) => out["postCommitError"] = json!(e.to_string()),+                }+            }+        }+        Err(e) => out["postCommitError"] = json!(e.message),+    }+    out+}++/// `kicad_route_diff_pair`: both halves of a pair from one centerline, one Undo step.+pub fn route_diff_pair(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text, data, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let pro = project_of(&s);+    let plan = route_tools::plan_diff_pair(&data, pro.as_ref(), args)?;+    let check = preflight(ctx, &s, &text, &pcb::splice(&text, &plan.blocks()), args, "The diff pair")?;+    let mut out = plan.public();+    if args.get("dryRun") == Some(&json!(true)) {+        merge(&mut out, &json!({"success": true, "dryRun": true, "mutated": false, "revision": revision, "drc": check, "source": "live-editor",+            "_hint": "Both halves planned and checked without editing. Submit with dryRun:false and the same revision; skewMm is the length difference to tune."}));+        return Ok(out);+    }+    check_revision(args, &s.revision()?)?;+    let ids = commit_plans(&s, &[&plan.p, &plan.n], &format!("Adom diff pair {} / {}", plan.p.net_name, plan.n.net_name))?;+    merge(&mut out, &json!({"success": true, "mutated": true, "source": "live-editor", "itemIds": ids, "undoSteps": 1, "drc": check, "saved": false,+        "_hint": "Pair committed live as one KiCad Undo step. skewMm is the P/N length difference; tune it in KiCad (Route > Tune Skew) or reroute the shorter half."}));+    Ok(finish(&s, args, out))+}++/// `kicad_copy_routing`: tracks and vias in a box, translated and net-remapped, one Undo step.+pub fn copy_routing(ctx: &Ctx, args: &Value) -> RResult<Value> {+    let s = connect(ctx, args)?;+    let (text, data, revision) = s.snapshot()?;+    check_revision(args, &revision)?;+    let plan = route_tools::plan_copy(&data, args)?;+    let check = preflight(ctx, &s, &text, &pcb::splice(&text, &plan.blocks()), args, "The copied routing")?;+    let mut out = plan.public();+    if args.get("dryRun") == Some(&json!(true)) {+        merge(&mut out, &json!({"success": true, "dryRun": true, "mutated": false, "revision": revision, "drc": check, "source": "live-editor",+            "_hint": "Copy planned and checked without editing. Submit with dryRun:false and the same revision."}));+        return Ok(out);+    }+    check_revision(args, &s.revision()?)?;+    let plans: Vec<&Plan> = plan.plans.iter().collect();+    let ids = commit_plans(&s, &plans, &format!("Adom copy routing ({} nets)", plans.len()))?;+    merge(&mut out, &json!({"success": true, "mutated": true, "source": "live-editor", "itemIds": ids, "undoSteps": 1, "drc": check, "saved": false,+        "_hint": "Copied live as one KiCad Undo step. Check kicad_routing_validate: copied copper connects only where the target pads sit at the same offset."}));+    Ok(finish(&s, args, out))+}++#[cfg(test)]+mod tests {+    use super::*;+    use crate::route_tools::TrackGeom;++    #[test]+    fn a_track_change_rewrites_width_layer_and_ends_and_keeps_the_rest() {+        let mut t = TrackItem::from_proto(Default::default());+        t.set_start_nm(Vector2Nm { x_nm: 1, y_nm: 2 });+        let p = t.proto_mut();+        p.net = Some(Default::default());+        if let Some(n) = p.net.as_mut() {+            n.name = "SIG".into();+        }+        let geom = |w: f64, l: &str| TrackGeom { start: [1.0, 2.0], end: [3.5, 2.0], width: w, layer: l.into() };+        let c = TrackChange { id: "x".into(), net: "SIG".into(), before: geom(0.2, "F.Cu"), after: geom(0.4, "B.Cu"), changed: vec!["width", "layer"] };+        apply_track_change(&mut t, &c).unwrap();+        assert_eq!(t.proto().width.as_ref().unwrap().value_nm, 400_000);+        assert_eq!(t.layer_id(), BoardLayerInfo::id_from_name("B.Cu").unwrap());+        assert_eq!(t.start_nm(), Some(Vector2Nm { x_nm: 1_000_000, y_nm: 2_000_000 }));+        assert_eq!(t.end_nm(), Some(Vector2Nm { x_nm: 3_500_000, y_nm: 2_000_000 }));+        assert_eq!(t.proto().net.as_ref().unwrap().name, "SIG", "the net is left as KiCad sent it");+        let bad = TrackChange { after: geom(0.4, "Nope.Cu"), ..c };+        assert_eq!(apply_track_change(&mut t, &bad).unwrap_err().code, "unknown_layer");+    }+}
rust/crates/kicad-core/src/ipc/silk_fields.rs+6−3
@@ -234,13 +234,16 @@ pub fn silk_fields_batch(ctx: &Ctx, args: &Value) -> RResult<Value> {         Ok((current, _, rev)) => {             out["revision"] = json!(rev);             let set = ids.iter().cloned().collect();-            let preserved = match (+            let (preserved, diff) = match (                 fields::preserved_geometry(&text, &set),                 fields::preserved_geometry(&current, &set),             ) {-                (Ok(before), Ok(after)) => before == after,-                _ => false,+                (Ok(before), Ok(after)) => (before == after, fields::preservation_diff(&before, &after, 12)),+                _ => (false, Vec::new()),             };+            if !diff.is_empty() {+                out["preservationDiff"] = json!(diff);+            }             out["otherBoardDataVerified"] = json!(preserved);             if !preserved {                 out["success"] = json!(false);
rust/crates/kicad-core/src/lib.rs+23
@@ -10,6 +10,7 @@ pub mod model_check; pub mod pcm; pub mod updates; pub mod schematic;+pub mod sch_edit; pub mod kicad_common; pub mod render; pub mod bridge_log;@@ -18,6 +19,10 @@ pub mod pcb; pub mod board_model; pub mod bodies; pub mod placement;+pub mod place_tools;+pub mod route_tools;+pub mod project_rules;+pub mod pcb_sync; pub mod zones; #[cfg(feature = "ipc")] pub mod ipc;@@ -38,3 +43,21 @@ pub mod zone_plan; pub mod silk_graphics;  pub mod silk_fields;++pub mod fab;+pub mod dfm;+pub mod project;+pub mod fpedit;+pub mod symedit;+pub mod libsearch;+pub mod sxedit;+pub mod fp_text;+pub mod svg_import;+pub mod board_edit;+// Schematic hierarchy, analysis, render and design review (kicad_sch_hierarchy and friends).+pub mod sch_model;+pub mod sch_hier;+pub mod sch_check;+pub mod sch_review;+pub mod sch_render;+pub mod sch_sheets;
rust/crates/kicad-core/src/libsearch.rsadded+272
@@ -0,0 +1,272 @@+//! kicad_search_library: find symbols and footprints across every registered library+//! (global and project sym-lib-table / fp-lib-table, URIs expanded the way KiCad expands+//! them) by name, keyword and description, ranked. Library contents are cached per file+//! by modification time, so a second search over the stock libraries is fast.++use std::collections::HashMap;+use std::path::{Path, PathBuf};+use std::sync::Mutex;+use std::time::SystemTime;++use serde_json::{json, Value};++use crate::libraries::{expand_kicad_uri, list_pretty, norm_path, LibCtx, LibTable};++#[derive(Clone, Debug, PartialEq)]+pub struct LibRef {+    pub kind: &'static str,+    pub nickname: String,+    pub path: PathBuf,+    pub scope: &'static str,+}++#[derive(Clone, Debug)]+pub struct Entry {+    pub name: String,+    pub description: String,+    pub keywords: String,+}++/// Every enabled library of `kind` ("symbol" or "footprint"): the project's table first+/// (it wins on a nickname clash, as in KiCad), then the global table.+pub fn registered(ctx: &LibCtx, project_dir: Option<&Path>, kind: &'static str) -> (Vec<LibRef>, Vec<String>) {+    let mut out: Vec<LibRef> = Vec::new();+    let mut skipped: Vec<String> = Vec::new();+    let table_name = if kind == "symbol" { "sym-lib-table" } else { "fp-lib-table" };+    let mut tables: Vec<(PathBuf, &'static str)> = Vec::new();+    if let Some(p) = project_dir {+        tables.push((p.join(table_name), "project"));+    }+    if let Some(g) = if kind == "symbol" { ctx.sym_lib_table() } else { ctx.fp_lib_table() } {+        tables.push((g, "global"));+    }+    for (t, scope) in tables {+        if !t.is_file() {+            continue;+        }+        let Ok(table) = LibTable::parse_file_resolved(&t) else {+            skipped.push(format!("{} (unreadable)", norm_path(&t)));+            continue;+        };+        for e in table.entries.iter().filter(|e| !e.disabled) {+            if out.iter().any(|r| r.nickname == e.name) {+                continue;+            }+            let uri = match project_dir {+                Some(p) => e.uri.replace("${KIPRJMOD}", &norm_path(p)),+                None => e.uri.clone(),+            };+            match expand_kicad_uri(&uri, ctx) {+                Some(p) => out.push(LibRef { kind, nickname: e.name.clone(), path: PathBuf::from(p), scope }),+                None => skipped.push(format!("{} ({})", e.name, e.uri)),+            }+        }+    }+    (out, skipped)+}++static CACHE: Mutex<Option<HashMap<PathBuf, (SystemTime, Vec<Entry>)>>> = Mutex::new(None);++fn mtime(p: &Path) -> Option<SystemTime> {+    std::fs::metadata(p).and_then(|m| m.modified()).ok()+}++/// The quoted value after `(property "<key>" ` in a block, if present.+fn property_in(block: &str, key: &str) -> String {+    let pat = format!("(property \"{key}\" \"");+    let Some(i) = block.find(&pat) else { return String::new() };+    let rest = &block[i + pat.len()..];+    let mut out = String::new();+    let mut esc = false;+    for c in rest.chars() {+        if esc {+            out.push(c);+            esc = false;+        } else if c == '\\' {+            esc = true;+        } else if c == '"' {+            break;+        } else {+            out.push(c);+        }+    }+    out+}++/// `(descr "...")` / `(tags "...")` near the top of a footprint file.+fn atom_in(text: &str, key: &str) -> String {+    let pat = format!("({key} \"");+    let Some(i) = text.find(&pat) else { return String::new() };+    text[i + pat.len()..].split('"').next().unwrap_or("").to_string()+}++/// Symbols of a .kicad_sym with their description and keywords.+pub fn symbol_entries(text: &str) -> Vec<Entry> {+    let (blocks, _) = crate::libraries::extract_symbols_tolerant(text);+    blocks+        .into_iter()+        .map(|(name, block)| Entry { description: property_in(&block, "Description"), keywords: property_in(&block, "ki_keywords"), name })+        .collect()+}++fn entries_for(r: &LibRef) -> Vec<Entry> {+    let key = r.path.clone();+    let stamp = if r.kind == "symbol" { mtime(&r.path) } else { mtime(&r.path) };+    if let (Some(t), Ok(g)) = (stamp, CACHE.lock()) {+        if let Some((ct, e)) = g.as_ref().and_then(|m| m.get(&key)) {+            if *ct == t {+                return e.clone();+            }+        }+    }+    let entries: Vec<Entry> = if r.kind == "symbol" {+        std::fs::read_to_string(&r.path).map(|t| symbol_entries(&t)).unwrap_or_default()+    } else {+        list_pretty(&r.path)+            .into_iter()+            .map(|name| {+                // descr and tags sit in the first lines; read a bounded head only.+                let mut head = String::new();+                if let Ok(mut f) = std::fs::File::open(r.path.join(format!("{name}.kicad_mod"))) {+                    use std::io::Read;+                    let mut buf = vec![0u8; 4096];+                    if let Ok(n) = f.read(&mut buf) {+                        head = String::from_utf8_lossy(&buf[..n]).to_string();+                    }+                }+                Entry { description: atom_in(&head, "descr"), keywords: atom_in(&head, "tags"), name }+            })+            .collect()+    };+    if let (Some(t), Ok(mut g)) = (stamp, CACHE.lock()) {+        g.get_or_insert_with(HashMap::new).insert(key, (t, entries.clone()));+    }+    entries+}++/// Rank one entry against the query tokens; None when a token matches nowhere.+pub fn score(q: &str, tokens: &[String], nickname: &str, e: &Entry) -> Option<f64> {+    let name = e.name.to_lowercase();+    let lib = nickname.to_lowercase();+    let kw = e.keywords.to_lowercase();+    let desc = e.description.to_lowercase();+    let mut s = 0.0;+    for t in tokens {+        let mut hit = false;+        if name.contains(t.as_str()) {+            s += 15.0;+            hit = true;+        }+        if kw.split_whitespace().any(|k| k == t) {+            s += 10.0;+            hit = true;+        } else if kw.contains(t.as_str()) {+            s += 6.0;+            hit = true;+        }+        if desc.contains(t.as_str()) {+            s += 4.0;+            hit = true;+        }+        if lib.contains(t.as_str()) {+            s += 3.0;+            hit = true;+        }+        if !hit {+            return None;+        }+    }+    let ql = q.to_lowercase();+    let full = format!("{lib}:{name}");+    if name == ql || full == ql {+        s += 100.0;+    } else if name.starts_with(&ql) {+        s += 50.0;+    } else if name.contains(&ql) {+        s += 25.0;+    }+    // shorter names first among equals: "R" before "R_Pack08"+    s -= (name.len() as f64) * 0.01;+    Some(s)+}++/// Search `libs` for `query`. Returns (ranked hits, libraries scanned, entries scanned).+pub fn search(libs: &[LibRef], query: &str, limit: usize) -> (Vec<Value>, usize, usize) {+    let tokens: Vec<String> = query.to_lowercase().split(|c: char| c.is_whitespace() || c == ',').filter(|t| !t.is_empty()).map(str::to_string).collect();+    let mut hits: Vec<(f64, Value)> = Vec::new();+    let mut scanned = 0;+    let mut libs_scanned = 0;+    for r in libs {+        if !(r.path.is_file() || r.path.is_dir()) {+            continue;+        }+        libs_scanned += 1;+        for e in entries_for(r) {+            scanned += 1;+            if let Some(s) = score(query, &tokens, &r.nickname, &e) {+                hits.push((s, json!({+                    "kind": r.kind, "libId": format!("{}:{}", r.nickname, e.name), "library": r.nickname, "name": e.name,+                    "description": e.description, "keywords": e.keywords, "scope": r.scope, "libraryPath": norm_path(&r.path),+                    "score": (s * 10.0).round() / 10.0,+                })));+            }+        }+    }+    hits.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap().then_with(|| a.1["libId"].as_str().cmp(&b.1["libId"].as_str())));+    (hits.into_iter().take(limit.max(1)).map(|(_, v)| v).collect(), libs_scanned, scanned)+}++#[cfg(test)]+mod tests {+    use super::*;++    fn repo(rel: &str) -> PathBuf {+        Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)+    }++    #[test]+    fn project_tables_expand_kiprjmod_and_rank_hits() {+        let d = std::env::temp_dir().join(format!("kc-libsearch-{}-{}", std::process::id(), crate::schematic::new_uuid()));+        std::fs::create_dir_all(&d).unwrap();+        let sym = norm_path(&repo("pcm/src/adom-sample-lib/symbols"));+        let fp = norm_path(&repo("pcm/src/adom-sample-lib/footprints"));+        std::fs::write(d.join("sym-lib-table"), format!("(sym_lib_table (version 7) (lib (name \"Sample\") (type \"KiCad\") (uri \"{sym}/Adom_Sample.kicad_sym\") (options \"\") (descr \"\")) (lib (name \"Off\") (type \"KiCad\") (uri \"${{KIPRJMOD}}/off.kicad_sym\") (options \"\") (descr \"\") (disabled)))")).unwrap();+        std::fs::write(d.join("fp-lib-table"), format!("(fp_lib_table (version 7) (lib (name \"SampleFp\") (type \"KiCad\") (uri \"{fp}/Adom_Sample.pretty\") (options \"\") (descr \"\")) (lib (name \"Local\") (type \"KiCad\") (uri \"${{KIPRJMOD}}/Local.pretty\") (options \"\") (descr \"\")))")).unwrap();+        std::fs::create_dir_all(d.join("Local.pretty")).unwrap();+        std::fs::write(d.join("Local.pretty/MyPad.kicad_mod"), "(footprint \"MyPad\" (version 20260206) (descr \"test pad for resistor probing\") (tags \"probe\"))").unwrap();+        let ctx = LibCtx::default();+        let (syms, skipped) = registered(&ctx, Some(&d), "symbol");+        assert_eq!(syms.len(), 1, "the disabled row is left out");+        assert!(skipped.is_empty());+        let (fps, _) = registered(&ctx, Some(&d), "footprint");+        assert_eq!(fps.len(), 2);+        assert_eq!(fps[1].path, PathBuf::from(norm_path(&d.join("Local.pretty"))));++        let (hits, libs, scanned) = search(&syms, "resistor", 10);+        assert_eq!(libs, 1);+        assert!(scanned >= 10);+        assert_eq!(hits[0]["libId"], "Sample:AdomSample_R", "{hits:?}");+        let (hits, _, _) = search(&syms, "AdomSample_C", 10);+        assert_eq!(hits[0]["name"], "AdomSample_C", "exact name first");+        let mut all = syms.clone();+        all.extend(fps.clone());+        let (hits, _, _) = search(&all, "soic 8", 5);+        assert!(hits.iter().any(|h| h["kind"] == "footprint" && h["name"].as_str().unwrap().contains("SOIC-8")), "{hits:?}");+        let (hits, _, _) = search(&fps, "probe", 5);+        assert_eq!(hits[0]["libId"], "Local:MyPad");+        assert_eq!(hits[0]["scope"], "project");+        let (none, _, _) = search(&all, "zzzz-no-such-thing", 5);+        assert!(none.is_empty());+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn scoring_requires_every_token() {+        let e = Entry { name: "LM358".into(), description: "Dual operational amplifier".into(), keywords: "opamp dual".into() };+        assert!(score("dual opamp", &["dual".into(), "opamp".into()], "Amplifier_Operational", &e).is_some());+        assert!(score("quad opamp", &["quad".into(), "opamp".into()], "Amplifier_Operational", &e).is_none());+        let exact = score("lm358", &["lm358".into()], "X", &e).unwrap();+        let loose = score("amplifier", &["amplifier".into()], "X", &e).unwrap();+        assert!(exact > loose);+    }+}
rust/crates/kicad-core/src/netlist.rs+15
@@ -34,6 +34,12 @@ pub struct Component {     pub footprint: String,     pub libsource: String,     pub description: String,+    /// The symbol's uuid (`<tstamps>`) and its sheet path (`<sheetpath tstamps>`): together the+    /// `(path ...)` a board footprint carries to link back to it.+    #[serde(skip)]+    pub tstamp: String,+    #[serde(skip)]+    pub sheet_tstamps: String, }  #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]@@ -169,6 +175,14 @@ fn open_element(                 }             }         }+        "sheetpath" if comp_child => {+            if let Some((_, c, _)) = comp.as_mut() {+                c.sheet_tstamps = attr(e, "tstamps");+            }+        }+        "tstamps" if comp_child => {+            *text_target = Some((name.clone(), String::new()));+        }         "value" | "footprint" | "description" if comp_child => {             // Python findtext returns the first matching child; a repeat is ignored.             let already = comp.as_ref().map(|(_, c, _)| match name.as_str() {@@ -209,6 +223,7 @@ fn close_element(                     "value" => c.value = t,                     "footprint" => c.footprint = t,                     "description" => c.description = t,+                    "tstamps" => c.tstamp = t,                     _ => {}                 }             }
rust/crates/kicad-core/src/pcb.rs+4
@@ -99,6 +99,9 @@ pub struct FpPad {     pub width: Option<f64>,     #[serde(skip_serializing_if = "Option::is_none")]     pub height: Option<f64>,+    /// The pad's `(at x y angle)` angle: KiCad writes it absolute (footprint orientation included).+    #[serde(skip_serializing_if = "Option::is_none")]+    pub rotation: Option<f64>,     #[serde(skip_serializing_if = "Option::is_none")]     pub layers: Option<Vec<String>>,     /// Present when the pad carries a (net ...) reference at all (either dialect).@@ -441,6 +444,7 @@ fn extract_footprints(tree: &Sx, name_based: bool) -> Vec<Footprint> {                     y: at.and_then(|a| atom_f64(a, 2)),                     width: size.and_then(|s| atom_f64(s, 1)),                     height: size.and_then(|s| atom_f64(s, 2)),+                    rotation: at.and_then(|a| atom_f64(a, 3)),                     layers: pad.find("layers").map(string_atoms),                     net_number: if has_net { Some(num) } else { None },                     net_name: if has_net { Some(name.unwrap_or_default()) } else { None },
rust/crates/kicad-core/src/pcb_sync.rsadded+541
@@ -0,0 +1,541 @@+//! Update PCB from Schematic, on the file: compare the schematic's netlist with the board+//! (missing, extra and changed footprints, changed values, pad nets) and, on a CLOSED+//! board, apply the plan: new footprints from the libraries in a row below the board,+//! values and links updated, pad nets assigned, extras marked (deleted only on request).+//!+//! Footprints are matched by reference, the way KiCad's "Re-link footprints to schematic+//! symbols based on their reference designators" option does; the symbol uuid becomes the+//! footprint's `(path ...)` so KiCad's own F8 matches them by uuid afterwards.++use std::collections::{BTreeMap, HashMap};+use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::libraries::{self, LibCtx, LibTable};+use crate::netlist::{Component, Netlist};+use crate::pcb::{self, Board, NetFormat, RoutingError};+use crate::placement;+use crate::route_tools::top_level_spans;+use crate::schematic::new_uuid;++type R<T> = Result<T, RoutingError>;++/// The marker property an extra footprint gets on apply.+pub const EXTRA_MARK: &str = "Adom_NotInSchematic";++fn q(s: &str) -> String {+    serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))+}++/// `(path ...)` for a netlist component: its sheet path plus its symbol uuid.+pub fn symbol_path(c: &Component) -> Option<String> {+    if c.tstamp.is_empty() {+        return None;+    }+    let mut sheet = if c.sheet_tstamps.is_empty() { "/".to_string() } else { c.sheet_tstamps.clone() };+    if !sheet.ends_with('/') {+        sheet.push('/');+    }+    Some(format!("{sheet}{}", c.tstamp))+}++// ---------------------------------------------------------------------------+// Compare+// ---------------------------------------------------------------------------++fn footprint_spans(text: &str) -> Vec<(usize, usize)> {+    top_level_spans(text).into_iter().filter(|(s, _)| text[*s..].starts_with("(footprint")).collect()+}++/// The span of the board footprint with this reference.+pub fn footprint_span(text: &str, reference: &str) -> Option<(usize, usize)> {+    let needle = format!("(property \"Reference\" {}", q(reference));+    let legacy = format!("(fp_text reference {}", q(reference));+    footprint_spans(text).into_iter().find(|(s, e)| text[*s..*e].contains(&needle) || text[*s..*e].contains(&legacy))+}++fn board_only(text: &str, reference: &str) -> bool {+    footprint_span(text, reference).map(|(s, e)| text[s..e].lines().any(|l| l.trim_start().starts_with("(attr") && l.contains("board_only"))).unwrap_or(false)+}++/// The difference between the netlist and the board, as the plan reports it.+pub fn compare(board: &Board, text: &str, nl: &Netlist) -> Value {+    let on_board: HashMap<&str, &pcb::Footprint> = board.footprints.iter().map(|f| (f.reference.as_str(), f)).collect();+    let mut missing = Vec::new();+    let mut no_footprint = Vec::new();+    let mut values = Vec::new();+    let mut fps = Vec::new();+    let mut links = Vec::new();+    for c in &nl.components {+        match on_board.get(c.reference.as_str()) {+            None if c.footprint.is_empty() => no_footprint.push(json!(c.reference)),+            None => missing.push(json!({"ref": c.reference, "value": c.value, "footprint": c.footprint})),+            Some(f) => {+                if f.value != c.value {+                    values.push(json!({"ref": c.reference, "from": f.value, "to": c.value}));+                }+                if !c.footprint.is_empty() && f.footprint != c.footprint {+                    fps.push(json!({"ref": c.reference, "from": f.footprint, "to": c.footprint}));+                }+                if let Some(p) = symbol_path(c) {+                    if f.path.as_deref() != Some(p.as_str()) {+                        links.push(json!({"ref": c.reference, "from": f.path, "to": p}));+                    }+                }+            }+        }+    }+    let in_netlist: Vec<&str> = nl.components.iter().map(|c| c.reference.as_str()).collect();+    let mut extra = Vec::new();+    let mut kept = Vec::new();+    for f in &board.footprints {+        if in_netlist.contains(&f.reference.as_str()) {+            continue;+        }+        if board_only(text, &f.reference) {+            kept.push(json!(f.reference));+        } else {+            extra.push(json!({"ref": f.reference, "value": f.value, "footprint": f.footprint}));+        }+    }+    let pad_nets = pad_net_changes(board, nl);+    let total = missing.len() + extra.len() + values.len() + fps.len() + pad_nets.len() + links.len();+    json!({+        "inSync": total == 0, "changeCount": total,+        "missingFootprints": missing, "extraFootprints": extra, "boardOnlyFootprints": kept,+        "changedValues": values, "changedFootprints": fps, "padNetChanges": pad_nets, "relinks": links,+        "componentsWithoutFootprint": no_footprint,+        "counts": {"schematicComponents": nl.components.len(), "boardFootprints": board.footprints.len(), "schematicNets": nl.nets.len()},+    })+}++fn expected_nets(nl: &Netlist) -> HashMap<(String, String), String> {+    let mut m = HashMap::new();+    for n in &nl.nets {+        for node in &n.nodes {+            m.insert((node.reference.clone(), node.pin.clone()), n.name.clone());+        }+    }+    m+}++/// Every pad of a footprint the netlist knows whose net differs from the netlist's.+pub fn pad_net_changes(board: &Board, nl: &Netlist) -> Vec<Value> {+    let want = expected_nets(nl);+    let known: Vec<&str> = nl.components.iter().map(|c| c.reference.as_str()).collect();+    let mut out = Vec::new();+    for f in &board.footprints {+        if !known.contains(&f.reference.as_str()) {+            continue;+        }+        let mut seen = Vec::new();+        for p in &f.pads {+            if p.number.is_empty() || seen.contains(&p.number) {+                continue;+            }+            seen.push(p.number.clone());+            let have = p.net_name.clone().unwrap_or_default();+            let to = want.get(&(f.reference.clone(), p.number.clone())).cloned().unwrap_or_default();+            // KiCad names an unconnected pin's net "unconnected-(...)": a pad with no net is the same thing+            let same = have == to || (have.is_empty() && to.starts_with("unconnected-")) || (to.is_empty() && have.starts_with("unconnected-"));+            if !same {+                out.push(json!({"pad": format!("{}.{}", f.reference, p.number), "from": have, "to": to}));+            }+        }+    }+    out+}++// ---------------------------------------------------------------------------+// Text edits inside a footprint block+// ---------------------------------------------------------------------------++/// Spans of the direct children of the list at `span`.+pub fn child_spans(text: &str, span: (usize, usize)) -> Vec<(usize, usize)> {+    let inner = &text[span.0..span.1];+    top_level_spans(inner).into_iter().map(|(a, b)| (a + span.0, b + span.0)).collect()+}++fn set_property(block: &str, key: &str, value: &str) -> String {+    let head = format!("(property {} ", q(key));+    if let Some(i) = block.find(&head) {+        let start = i + head.len();+        if block[start..].starts_with('"') {+            // the quoted value: find its closing quote, escapes respected+            let mut j = start + 1;+            let b = block.as_bytes();+            while j < b.len() {+                if b[j] == b'\\' {+                    j += 2;+                    continue;+                }+                if b[j] == b'"' {+                    break;+                }+                j += 1;+            }+            let mut out = block.to_string();+            out.replace_range(start..=j.min(block.len() - 1), &q(value));+            return out;+        }+    }+    block.to_string()+}++fn net_clause(fmt: NetFormat, number: Option<i64>, name: &str) -> String {+    match fmt {+        NetFormat::Name => format!("(net {})", q(name)),+        NetFormat::Number => format!("(net {} {})", number.unwrap_or(0), q(name)),+    }+}++/// Set (or clear, with `None`) the net of every pad numbered `pad` in the footprint block.+fn set_pad_net(block: &str, pad: &str, net: Option<&str>, fmt: NetFormat, numbers: &BTreeMap<String, i64>) -> String {+    let mut out = block.to_string();+    let head = format!("(pad {} ", q(pad));+    // walk from the end so earlier spans stay valid+    let pads: Vec<(usize, usize)> = child_spans(&out, (0, out.len())).into_iter().filter(|(s, _)| out[*s..].starts_with(&head)).collect();+    for (s, e) in pads.into_iter().rev() {+        let pad_text = out[s..e].to_string();+        let children = child_spans(&pad_text, (0, pad_text.len()));+        let existing = children.iter().find(|(a, _)| pad_text[*a..].starts_with("(net "));+        let clause = net.map(|n| net_clause(fmt, numbers.get(n).copied(), n));+        let new_pad = match (existing, clause) {+            (Some((a, b)), Some(c)) => format!("{}{}{}", &pad_text[..*a], c, &pad_text[*b..]),+            (Some((a, b)), None) => {+                let mut a2 = *a;+                while a2 > 0 && matches!(pad_text.as_bytes()[a2 - 1], b' ' | b'\t') {+                    a2 -= 1;+                }+                if a2 > 0 && pad_text.as_bytes()[a2 - 1] == b'\n' {+                    a2 -= 1;+                }+                format!("{}{}", &pad_text[..a2], &pad_text[*b..])+            }+            (None, Some(c)) => {+                // before the uuid when there is one, else before the closing paren+                match children.iter().find(|(a, _)| pad_text[*a..].starts_with("(uuid")) {+                    Some((a, _)) => format!("{}{}\n\t\t\t{}", &pad_text[..*a], c, &pad_text[*a..]),+                    None => format!("{} {})", pad_text[..pad_text.len() - 1].trim_end(), c),+                }+            }+            (None, None) => pad_text.clone(),+        };+        out.replace_range(s..e, &new_pad);+    }+    out+}++fn add_angle(block: &str, rot: f64) -> String {+    if rot == 0.0 {+        return block.to_string();+    }+    let mut out = block.to_string();+    let targets: Vec<(usize, usize)> = child_spans(&out, (0, out.len())).into_iter().filter(|(s, _)| ["(pad ", "(property ", "(fp_text "].iter().any(|h| out[*s..].starts_with(h))).collect();+    for (s, e) in targets.into_iter().rev() {+        let item = out[s..e].to_string();+        let Some(at) = child_spans(&item, (0, item.len())).into_iter().find(|(a, _)| item[*a..].starts_with("(at ")) else { continue };+        let inner = &item[at.0 + 4..at.1 - 1];+        let nums: Vec<&str> = inner.split_whitespace().collect();+        if nums.len() < 2 {+            continue;+        }+        let a = nums.get(2).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0) + rot;+        let a = ((a % 360.0) + 360.0) % 360.0;+        let rest = if nums.len() > 3 { format!(" {}", nums[3..].join(" ")) } else { String::new() };+        let new_at = format!("(at {} {} {}{rest})", nums[0], nums[1], pcb::num(a));+        let mut it = item.clone();+        it.replace_range(at.0..at.1, &new_at);+        out.replace_range(s..e, &it);+    }+    out+}++/// A library `.kicad_mod` turned into a board footprint block: lib id, uuid, pose,+/// reference, value, schematic link, fresh child uuids, pad nets.+#[allow(clippy::too_many_arguments)]+pub fn instantiate(module: &str, lib_id: &str, reference: &str, value: &str, at: (f64, f64), rot: f64, layer_side: &str, uuid: &str, path: Option<&str>, pad_net: &dyn Fn(&str) -> Option<String>, fmt: NetFormat, numbers: &BTreeMap<String, i64>) -> R<String> {+    let t = module.trim();+    if !(t.starts_with("(footprint") || t.starts_with("(module")) {+        return Err(RoutingError::new("library_unreadable", format!("{lib_id}: not a .kicad_mod footprint")));+    }+    if layer_side != "F.Cu" {+        return Err(RoutingError::new("back_side_not_supported", "footprints are added on the front; flip them in KiCad afterwards"));+    }+    let spans = top_level_spans(t);+    let mut body = String::new();+    for (s, e) in &spans {+        let c = &t[*s..*e];+        if ["(version", "(generator", "(generator_version", "(uuid", "(at ", "(path", "(sheetname", "(sheetfile", "(tedit"].iter().any(|h| c.starts_with(h)) {+            continue;+        }+        body.push_str("\n\t\t");+        body.push_str(c);+    }+    let head_name = format!("(footprint {}", q(lib_id));+    let mut block = format!("{head_name}{body}\n\t)");+    // pose and identity right after the layer+    let ident = format!("\n\t\t(uuid {})\n\t\t(at {} {}{}){}", q(uuid), pcb::num(at.0), pcb::num(at.1), if rot != 0.0 { format!(" {}", pcb::num(rot)) } else { String::new() }, path.map(|p| format!("\n\t\t(path {})", q(p))).unwrap_or_default());+    match block.find("(layer ") {+        Some(i) => {+            let j = block[i..].find(')').map(|j| i + j + 1).unwrap_or(i);+            block.insert_str(j, &ident);+        }+        None => {+            let j = head_name.len();+            block.insert_str(j, &format!("\n\t\t(layer \"F.Cu\"){ident}"));+        }+    }+    // fresh uuids for every child+    let mut fresh = String::with_capacity(block.len());+    let mut rest = block.as_str();+    let mut first = true;+    while let Some(i) = rest.find("(uuid \"") {+        fresh.push_str(&rest[..i + 7]);+        let after = &rest[i + 7..];+        let j = after.find('"').unwrap_or(0);+        if first {+            fresh.push_str(&after[..j]);+            first = false;+        } else {+            fresh.push_str(&new_uuid());+        }+        rest = &after[j..];+    }+    fresh.push_str(rest);+    let mut block = set_property(&fresh, "Reference", reference);+    block = set_property(&block, "Value", value);+    block = add_angle(&block, rot);+    let mut numbers_seen = Vec::new();+    let parsed = pcb::parse_pcb_text(&format!("(kicad_pcb\n\t{block}\n)"), "lib").map_err(|e| RoutingError::new("library_unparsable", format!("{lib_id}: {e}")))?;+    if let Some(fp) = parsed.footprints.first() {+        for p in &fp.pads {+            if p.number.is_empty() || numbers_seen.contains(&p.number) {+                continue;+            }+            numbers_seen.push(p.number.clone());+            block = set_pad_net(&block, &p.number, pad_net(&p.number).as_deref(), fmt, numbers);+        }+    }+    Ok(format!("\t{block}"))+}++// ---------------------------------------------------------------------------+// Library lookup+// ---------------------------------------------------------------------------++/// The `.kicad_mod` for a `Lib:Name` id: the project's fp-lib-table first, then the global+/// one, `${KIPRJMOD}` resolved to the board's folder.+pub fn resolve_footprint(lib_id: &str, board_dir: &Path, ctx: &LibCtx) -> Result<PathBuf, String> {+    let (nick, name) = lib_id.split_once(':').ok_or_else(|| format!("{lib_id} is not Library:Footprint"))?;+    let mut tables = vec![board_dir.join("fp-lib-table")];+    if let Some(g) = ctx.fp_lib_table() {+        tables.push(g);+    }+    let prj = board_dir.to_string_lossy().replace('\\', "/");+    for t in tables.iter().filter(|t| t.is_file()) {+        let Ok(table) = LibTable::parse_file(t) else { continue };+        let Some(e) = table.get(nick) else { continue };+        if e.disabled {+            continue;+        }+        let uri = e.uri.replace("${KIPRJMOD}", &prj);+        let Some(dir) = libraries::expand_kicad_uri(&uri, ctx) else { return Err(format!("{nick}: cannot expand {}", e.uri)) };+        let f = Path::new(&dir).join(format!("{name}.kicad_mod"));+        return if f.is_file() { Ok(f) } else { Err(format!("{name}.kicad_mod is not in {dir}")) };+    }+    Err(format!("no library named {nick} in the project or global fp-lib-table"))+}++// ---------------------------------------------------------------------------+// Apply+// ---------------------------------------------------------------------------++pub struct ApplyOpts {+    pub delete_extra: bool,+    pub update_footprints: bool,+}++/// The board text with the plan applied, and what was done. `module` resolves a lib id+/// to its `.kicad_mod` text.+pub fn apply(text: &str, board: &Board, nl: &Netlist, opts: &ApplyOpts, module: &dyn Fn(&str) -> Result<String, String>) -> R<(String, Value)> {+    let fmt = board.net_format;+    let want = expected_nets(nl);+    let mut out = text.to_string();+    // nets a numbered board must declare+    let mut numbers: BTreeMap<String, i64> = board.nets.iter().filter_map(|n| n.number.map(|k| (n.name.clone(), k))).collect();+    let mut declared = Vec::new();+    if fmt == NetFormat::Number {+        let mut next = numbers.values().max().copied().unwrap_or(0) + 1;+        let mut decl = String::new();+        for n in &nl.nets {+            if !numbers.contains_key(&n.name) {+                numbers.insert(n.name.clone(), next);+                decl.push_str(&format!("\n\t(net {next} {})", q(&n.name)));+                declared.push(n.name.clone());+                next += 1;+            }+        }+        if !decl.is_empty() {+            let last = top_level_spans(&out).into_iter().filter(|(s, _)| out[*s..].starts_with("(net ")).last();+            let at = last.map(|(_, e)| e).ok_or_else(|| RoutingError::new("board_unparsable", "a numbered-net board without a net table"))?;+            out.insert_str(at, &decl);+        }+    }+    let mut done = json!({"valuesUpdated": [], "padNetsAssigned": [], "footprintsAdded": [], "footprintsReplaced": [], "extrasMarked": [], "extrasDeleted": [], "relinked": [], "notApplied": [], "netsDeclared": declared});+    let push = |d: &mut Value, k: &str, v: Value| d[k].as_array_mut().unwrap().push(v);+    let comps: HashMap<&str, &Component> = nl.components.iter().map(|c| (c.reference.as_str(), c)).collect();+    for f in &board.footprints {+        let Some(c) = comps.get(f.reference.as_str()) else { continue };+        let Some(span) = footprint_span(&out, &f.reference) else { continue };+        let mut block = out[span.0..span.1].to_string();+        let replace = opts.update_footprints && !c.footprint.is_empty() && f.footprint != c.footprint;+        if replace {+            let pos = f.position.clone().unwrap_or(pcb::Position { x: 0.0, y: 0.0, rotation: None });+            let side = f.layer.clone().unwrap_or_else(|| "F.Cu".into());+            let made = module(&c.footprint).map_err(|e| RoutingError::new("library_missing", e)).and_then(|m| {+                let net = |pad: &str| want.get(&(c.reference.clone(), pad.to_string())).cloned();+                instantiate(&m, &c.footprint, &c.reference, &c.value, (pos.x, pos.y), pos.rotation.unwrap_or(0.0), &side, f.uuid.as_deref().unwrap_or(&new_uuid()), symbol_path(c).as_deref().or(f.path.as_deref()), &net, fmt, &numbers)+            });+            match made {+                Ok(b) => {+                    out.replace_range(span.0..span.1, b.trim_start());+                    push(&mut done, "footprintsReplaced", json!({"ref": c.reference, "from": f.footprint, "to": c.footprint}));+                    continue;+                }+                Err(e) => push(&mut done, "notApplied", json!({"ref": c.reference, "change": "footprint", "to": c.footprint, "reason": e.message})),+            }+        }+        if f.value != c.value {+            block = set_property(&block, "Value", &c.value);+            push(&mut done, "valuesUpdated", json!({"ref": c.reference, "from": f.value, "to": c.value}));+        }+        if let Some(p) = symbol_path(c) {+            if f.path.as_deref() != Some(p.as_str()) {+                match child_spans(&block, (0, block.len())).into_iter().find(|(a, _)| block[*a..].starts_with("(path ")) {+                    Some((a, b)) => block.replace_range(a..b, &format!("(path {})", q(&p))),+                    None => {+                        if let Some(i) = block.find("(at ") {+                            let j = block[i..].find(')').map(|j| i + j + 1).unwrap_or(i);+                            block.insert_str(j, &format!("\n\t\t(path {})", q(&p)));+                        }+                    }+                }+                push(&mut done, "relinked", json!({"ref": c.reference, "path": p}));+            }+        }+        let mut seen = Vec::new();+        for p in &f.pads {+            if p.number.is_empty() || seen.contains(&p.number) {+                continue;+            }+            seen.push(p.number.clone());+            let have = p.net_name.clone().unwrap_or_default();+            let to = want.get(&(f.reference.clone(), p.number.clone())).cloned().unwrap_or_default();+            if have != to && !(have.is_empty() && to.starts_with("unconnected-")) {+                block = set_pad_net(&block, &p.number, if to.is_empty() { None } else { Some(&to) }, fmt, &numbers);+                push(&mut done, "padNetsAssigned", json!({"pad": format!("{}.{}", f.reference, p.number), "from": have, "to": to}));+            }+        }+        out.replace_range(span.0..span.1, &block);+    }+    // extras+    let known: Vec<&str> = nl.components.iter().map(|c| c.reference.as_str()).collect();+    for f in &board.footprints {+        if known.contains(&f.reference.as_str()) || board_only(&out, &f.reference) {+            continue;+        }+        let Some(span) = footprint_span(&out, &f.reference) else { continue };+        if opts.delete_extra {+            let mut s = span.0;+            while s > 0 && matches!(out.as_bytes()[s - 1], b' ' | b'\t') {+                s -= 1;+            }+            if s > 0 && out.as_bytes()[s - 1] == b'\n' {+                s -= 1;+            }+            out.replace_range(s..span.1, "");+            push(&mut done, "extrasDeleted", json!(f.reference));+        } else if !out[span.0..span.1].contains(EXTRA_MARK) {+            let mark = format!("\n\t\t(property {} \"yes\"\n\t\t\t(at 0 0 0)\n\t\t\t(layer \"F.Fab\")\n\t\t\t(hide yes)\n\t\t\t(uuid {})\n\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1 1)\n\t\t\t\t\t(thickness 0.15)\n\t\t\t\t)\n\t\t\t)\n\t\t)", q(EXTRA_MARK), q(&new_uuid()));+            let block = &out[span.0..span.1];+            let at = block.find("(property ").map(|i| span.0 + i).unwrap_or(span.1 - 1);+            // after the last property+            let kids = child_spans(&out, span);+            let after = kids.iter().filter(|(a, _)| out[*a..].starts_with("(property ")).last().map(|(_, b)| *b).unwrap_or(at);+            out.insert_str(after, &mark);+            push(&mut done, "extrasMarked", json!(f.reference));+        }+    }+    // missing footprints: a row below the outline+    let outline = placement::outline(board);+    let (mut x, y0) = outline.map(|o| (o.min_x, o.max_y + 5.0)).unwrap_or((0.0, 0.0));+    let mut added_blocks = String::new();+    for c in &nl.components {+        if board.footprints.iter().any(|f| f.reference == c.reference) || c.footprint.is_empty() {+            continue;+        }+        let m = match module(&c.footprint) {+            Ok(m) => m,+            Err(e) => {+                push(&mut done, "notApplied", json!({"ref": c.reference, "change": "add", "footprint": c.footprint, "reason": e}));+                continue;+            }+        };+        let net = |pad: &str| want.get(&(c.reference.clone(), pad.to_string())).cloned();+        // measure first at the origin, then place so the courtyard's left edge sits at x+        let probe = instantiate(&m, &c.footprint, &c.reference, &c.value, (0.0, 0.0), 0.0, "F.Cu", &new_uuid(), None, &net, fmt, &numbers)?;+        let parsed = pcb::parse_pcb_text(&format!("(kicad_pcb\n{probe}\n)"), "probe").map_err(|e| RoutingError::new("library_unparsable", e))?;+        let (r, _) = parsed.footprints.first().map(|f| placement::courtyard_at(f, (0.0, 0.0), 0.0)).unwrap_or((placement::Rect { min_x: -1.0, min_y: -1.0, max_x: 1.0, max_y: 1.0 }, "position"));+        let at = (pcb::round6(x - r.min_x), pcb::round6(y0 - r.min_y));+        let block = instantiate(&m, &c.footprint, &c.reference, &c.value, at, 0.0, "F.Cu", &new_uuid(), symbol_path(c).as_deref(), &net, fmt, &numbers)?;+        added_blocks.push('\n');+        added_blocks.push_str(&block);+        push(&mut done, "footprintsAdded", json!({"ref": c.reference, "footprint": c.footprint, "x": at.0, "y": at.1}));+        x += r.width() + 1.0;+    }+    if !added_blocks.is_empty() {+        let fps = footprint_spans(&out);+        let at = fps.last().map(|(_, e)| *e).unwrap_or_else(|| out.trim_end().len() - 1);+        out.insert_str(at, &added_blocks);+    }+    Ok((out, done))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn a_pad_net_is_set_replaced_and_cleared() {+        let block = "(footprint \"L:R\"\n\t\t(layer \"F.Cu\")\n\t\t(pad \"1\" smd rect\n\t\t\t(at 0 0)\n\t\t\t(net \"A\")\n\t\t\t(uuid \"u1\")\n\t\t)\n\t\t(pad \"2\" smd rect\n\t\t\t(at 1 0)\n\t\t\t(uuid \"u2\")\n\t\t)\n\t)";+        let m = BTreeMap::new();+        let b = set_pad_net(block, "1", Some("B"), NetFormat::Name, &m);+        assert!(b.contains("(net \"B\")") && !b.contains("(net \"A\")"));+        let b = set_pad_net(&b, "2", Some("C"), NetFormat::Name, &m);+        assert!(b.contains("(net \"C\")\n\t\t\t(uuid \"u2\")"), "{b}");+        let b = set_pad_net(&b, "1", None, NetFormat::Name, &m);+        assert!(!b.contains("(net \"B\")"));+        let mut nums = BTreeMap::new();+        nums.insert("C".to_string(), 7);+        assert!(set_pad_net(block, "1", Some("C"), NetFormat::Number, &nums).contains("(net 7 \"C\")"));+    }++    #[test]+    fn angles_add_to_pads_and_properties() {+        let block = "(footprint \"L:R\"\n\t(property \"Reference\" \"R1\" (at 0 -1 0))\n\t(pad \"1\" smd rect (at 1 0) (size 1 1))\n\t(fp_line (start 0 0) (end 1 1))\n)";+        let b = add_angle(block, 90.0);+        assert!(b.contains("(property \"Reference\" \"R1\" (at 0 -1 90))"), "{b}");+        assert!(b.contains("(pad \"1\" smd rect (at 1 0 90)"), "{b}");+        assert!(b.contains("(fp_line (start 0 0) (end 1 1))"));+    }++    #[test]+    fn the_symbol_path_joins_sheet_and_uuid() {+        let c = Component { reference: "R1".into(), tstamp: "abc".into(), sheet_tstamps: "/".into(), ..Default::default() };+        assert_eq!(symbol_path(&c).as_deref(), Some("/abc"));+        let c = Component { tstamp: "abc".into(), sheet_tstamps: "/s1".into(), ..Default::default() };+        assert_eq!(symbol_path(&c).as_deref(), Some("/s1/abc"));+    }+}
rust/crates/kicad-core/src/pcm.rs+172−1
@@ -57,12 +57,94 @@ pub type Fetch<'a> = &'a dyn Fn(&str, Duration) -> Result<Vec<u8>, String>;  /// GET a URL over HTTPS (rustls, bundled roots) with the PCM user agent. #[cfg(not(feature = "net"))]-pub fn http_fetch(_url: &str, _timeout: Duration) -> Result<Vec<u8>, String> {+pub fn http_fetch(url: &str, _timeout: Duration) -> Result<Vec<u8>, String> {+    if let Some(p) = file_url_path(url) {+        return std::fs::read(&p).map_err(|e| format!("OSError: {}: {e}", p.display()));+    }     Err("the net feature is off in this build: no HTTPS client".into()) } +/// `file:///C:/x/repository.json` or `file:///home/x/repository.json` as a local path (wiki #132).+/// KiCad's own PCM accepts file:// repositories and download URLs.+pub fn file_url_path(url: &str) -> Option<PathBuf> {+    let rest = url.strip_prefix("file://")?;+    let rest = rest.strip_prefix("localhost").unwrap_or(rest);+    let decoded = percent_decode(rest);+    // file:///C:/x -> "/C:/x": drop the slash before a drive letter.+    let b = decoded.as_bytes();+    let path = if b.len() > 2 && b[0] == b'/' && b[2] == b':' && b[1].is_ascii_alphabetic() { decoded[1..].to_string() } else { decoded };+    Some(PathBuf::from(path))+}++fn percent_decode(s: &str) -> String {+    let b = s.as_bytes();+    let mut out = Vec::with_capacity(b.len());+    let mut i = 0;+    while i < b.len() {+        if b[i] == b'%' && i + 2 < b.len() {+            if let Ok(v) = u8::from_str_radix(&s[i + 1..i + 3], 16) {+                out.push(v);+                i += 3;+                continue;+            }+        }+        out.push(b[i]);+        i += 1;+    }+    String::from_utf8_lossy(&out).into_owned()+}++/// A wiki.adom.inc URL that answered "not found / not allowed" anonymously: a private page.+/// Fetch it again through Adom Bridge's `desktop_wiki_fetch`, which carries the signed-in+/// user's wiki session (wiki #132). The body comes back base64 or as text depending on its type.+fn wiki_session_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {+    let reply = crate::ab::call("desktop_wiki_fetch", json!({"url": url, "reason": "install a KiCad PCM package from a private wiki page"}), timeout)+        .map_err(|e| format!("wiki session fetch through Adom Bridge failed: {e:?}"))?;+    if let Some(status) = reply.get("httpStatus").and_then(Value::as_u64) {+        if status >= 400 {+            return Err(format!("HTTPError: http status: {status} (signed in through Adom Bridge)"));+        }+    }+    for key in ["base64", "dataBase64", "bodyBase64", "contentBase64", "b64"] {+        if let Some(s) = reply.get(key).and_then(Value::as_str) {+            return crate::install::b64_decode(s);+        }+    }+    for key in ["text", "body", "content"] {+        if let Some(s) = reply.get(key).and_then(Value::as_str) {+            return Ok(s.as_bytes().to_vec());+        }+    }+    for key in ["savedTo", "path", "fullPath", "localPath"] {+        if let Some(p) = reply.get(key).and_then(Value::as_str) {+            if let Ok(b) = std::fs::read(p) {+                return Ok(b);+            }+        }+    }+    let keys: Vec<String> = reply.raw.as_object().map(|o| o.keys().cloned().collect()).unwrap_or_default();+    Err(format!("wiki session fetch answered without a body field the bridge knows (keys: {})", keys.join(", ")))+}++fn is_wiki_url(url: &str) -> bool {+    url.starts_with("https://wiki.adom.inc/")+}+ #[cfg(feature = "net")] pub fn http_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {+    if let Some(p) = file_url_path(url) {+        return std::fs::read(&p).map_err(|e| format!("OSError: {}: {e}", p.display()));+    }+    match anonymous_fetch(url, timeout) {+        Err(e) if is_wiki_url(url) && (e.contains("404") || e.contains("401") || e.contains("403")) => {+            wiki_session_fetch(url, timeout).map_err(|w| format!("{e}; then {w}"))+        }+        r => r,+    }+}++#[cfg(feature = "net")]+fn anonymous_fetch(url: &str, timeout: Duration) -> Result<Vec<u8>, String> {     let mut resp = ureq::get(url)         .header("User-Agent", UA)         .config()@@ -894,7 +976,64 @@ fn repository_url_for(installed: &Value, ident: &str, repos: &[(String, String)] /// answers upToDate, and otherwise the old files are replaced and installed_packages.json /// rewritten (previousVersion says what was there). A plain install of a version that /// differs from the installed one is the same replacement; `force` reinstalls the same one.+/// `kicad_pcm_install {zipPath}` (wiki #132): install a local package zip the same way a+/// repository install does. The zip's metadata.json becomes a one-package repository served+/// from memory, so the extraction, the registry row and every check are the same code.+fn install_from_zip(ctx: &Ctx, args: &Value, zip_path: &str) -> Value {+    let p = file_url_path(zip_path).unwrap_or_else(|| PathBuf::from(zip_path));+    let blob = match std::fs::read(&p) {+        Ok(b) => b,+        Err(e) => return json!({"success": false, "errorCode": "zip_unreadable", "error": format!("cannot read {}: {e}", p.display())}),+    };+    let meta: Value = match ZipFile::parse(&blob).and_then(|z| {+        let entry = z.entries.iter().find(|e| e.name == "metadata.json").ok_or_else(|| "the zip has no metadata.json at its root (not a KiCad PCM package)".to_string())?;+        let raw = z.read(entry)?;+        serde_json::from_slice::<Value>(&raw).map_err(|e| format!("metadata.json: {e}"))+    }) {+        Ok(m) => m,+        Err(e) => return json!({"success": false, "errorCode": "not_a_pcm_package", "error": e,+            "_hint": "A PCM package zip carries metadata.json beside its symbols/, footprints/ and 3dmodels/ folders."}),+    };+    let Some(ident) = meta.get("identifier").and_then(Value::as_str).map(String::from) else {+        return json!({"success": false, "errorCode": "not_a_pcm_package", "error": "metadata.json has no identifier"});+    };+    let zip_url = format!("file:///{}", p.to_string_lossy().replace('\\', "/").trim_start_matches('/'));+    let mut pkg = meta.clone();+    let mut versions = meta.get("versions").and_then(Value::as_array).cloned().unwrap_or_default();+    if versions.is_empty() {+        versions.push(json!({"version": "0.0.0", "status": "stable", "kicad_version": "6.0"}));+    }+    // The zip is one version: the one it says first (PCM writes the packaged version there).+    let mut v = versions[0].clone();+    v["download_url"] = json!(zip_url);+    v["download_sha256"] = json!(sha256_hex(&blob));+    v["download_size"] = json!(blob.len());+    pkg["versions"] = json!([v]);+    let packages = serde_json::to_vec(&json!({"packages": [pkg]})).unwrap_or_default();+    let repo_url = "local-zip://repository.json";+    let pkgs_url = "local-zip://packages.json";+    let repo = serde_json::to_vec(&json!({"name": "Local package zip", "packages": {"url": pkgs_url, "sha256": sha256_hex(&packages)}})).unwrap_or_default();+    let serve = move |url: &str, t: Duration| -> Result<Vec<u8>, String> {+        match url {+            u if u == repo_url => Ok(repo.clone()),+            u if u == pkgs_url => Ok(packages.clone()),+            u => http_fetch(u, t),+        }+    };+    let mut a = args.clone();+    a["identifier"] = json!(ident);+    a["repositoryUrl"] = json!(repo_url);+    a.as_object_mut().map(|o| o.remove("zipPath"));+    let mut out = install(ctx, &a, &serve);+    out["zipPath"] = json!(p.to_string_lossy());+    out["_note"] = json!("Installed from a local zip: KiCad's PCM shows it as installed but cannot check it for updates (no repository URL). Install from the repository instead when it is reachable.");+    out+}+ pub fn install(ctx: &Ctx, args: &Value, fetch: Fetch) -> Value {+    if let Some(zip) = args.get("zipPath").and_then(Value::as_str).filter(|z| !z.trim().is_empty()) {+        return install_from_zip(ctx, args, zip.trim());+    }     let s = |k: &str| args.get(k).and_then(Value::as_str).map(|v| v.trim().to_string()).filter(|v| !v.is_empty());     let (auto_add, default_prefix) = pcm_prefs(ctx);     let prefix = s("libPrefix").unwrap_or(default_prefix);@@ -1162,6 +1301,21 @@ mod tests {     use super::*;     use std::collections::HashMap; +    #[test]+    fn file_urls_read_local_files() {+        assert_eq!(file_url_path("file:///C:/Users/a/pcm/repository.json").unwrap(), PathBuf::from("C:/Users/a/pcm/repository.json"));+        assert_eq!(file_url_path("file:///home/box/pcm/repository.json").unwrap(), PathBuf::from("/home/box/pcm/repository.json"));+        assert_eq!(file_url_path("file://localhost/home/a%20b/r.json").unwrap(), PathBuf::from("/home/a b/r.json"));+        assert!(file_url_path("https://wiki.adom.inc/x").is_none());+        let dir = std::env::temp_dir().join(format!("pcm-file-url-{}", std::process::id()));+        std::fs::create_dir_all(&dir).unwrap();+        let f = dir.join("repository.json");+        std::fs::write(&f, b"{}").unwrap();+        let url = format!("file://{}{}", if cfg!(windows) { "/" } else { "" }, f.to_string_lossy().replace('\\', "/"));+        assert_eq!(http_fetch(&url, Duration::from_secs(1)).unwrap(), b"{}");+        let _ = std::fs::remove_dir_all(&dir);+    }+     // Built with Python zipfile: metadata.json, plugins/__init__.py, symbols/Adom.kicad_sym,     // footprints/Adom.pretty/R.kicad_mod, 3dmodels/Adom.3dshapes/R.step (deflate), a     // footprints/ directory entry, colors/theme.json (stored).@@ -1258,6 +1412,23 @@ mod tests {         assert!(!third.join("metadata.json").exists());     } +    #[test]+    fn install_from_a_local_zip_like_a_repository() {+        let t = tmp("zipinstall");+        let c = ctx(&t.0);+        let zip = t.0.join("pkg.zip");+        std::fs::write(&zip, ZIP).unwrap();+        let unused = |u: &str, _t: Duration| -> Result<Vec<u8>, String> { Err(format!("no network in this test: {u}")) };+        let r = install(&c, &json!({"zipPath": zip.to_string_lossy()}), &unused);+        assert_eq!(r["success"], json!(true), "{r}");+        assert_eq!(r["identifier"], json!("inc.adom.kicad.test"));+        assert!(r["_note"].as_str().unwrap().contains("local zip"));+        let reg = read_installed(&t.0.join("config/installed_packages.json"));+        assert!(reg["packages"].as_array().unwrap().iter().any(|e| ident_of(e) == Some("inc.adom.kicad.test")), "{reg}");+        let bad = install(&c, &json!({"zipPath": t.0.join("missing.zip").to_string_lossy()}), &unused);+        assert_eq!(bad["errorCode"], json!("zip_unreadable"));+    }+     #[test]     fn install_list_uninstall_round_trip() {         let t = tmp("install");
rust/crates/kicad-core/src/place_tools.rsadded+684
@@ -0,0 +1,684 @@+//! Placement helpers on a parsed board: footprint-to-footprint clearance (courtyard or pad+//! copper), a 0-100 placement score with named deductions, a decoupling capacitor plan+//! beside an IC's power pins, and a BGA / QFN fanout plan whose calls `kicad_route_net`+//! takes as they are. Pure: nothing here talks to KiCad.+//!+//! Coordinates are millimetres in KiCad's board frame (+x right, +y DOWN); rotations are+//! degrees with KiCad's sign, as `crate::placement` uses them.++use serde_json::{json, Value};++use crate::pcb::{self, Board, Footprint, RoutingError};+use crate::placement::{self, Placement, Rect};++type R<T> = Result<T, RoutingError>;+type P = (f64, f64);++fn r3(v: f64) -> f64 {+    (v * 1000.0).round() / 1000.0+}++// ---------------------------------------------------------------------------+// Distance geometry+// ---------------------------------------------------------------------------++fn seg_point(p: P, a: P, b: P) -> f64 {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let l2 = dx * dx + dy * dy;+    let t = if l2 == 0.0 { 0.0 } else { (((p.0 - a.0) * dx + (p.1 - a.1) * dy) / l2).clamp(0.0, 1.0) };+    pcb::dist(p, (a.0 + t * dx, a.1 + t * dy))+}++fn cross(o: P, a: P, b: P) -> f64 {+    (a.0 - o.0) * (b.1 - o.1) - (a.1 - o.1) * (b.0 - o.0)+}++fn segs_cross(a: P, b: P, c: P, d: P) -> bool {+    let (d1, d2, d3, d4) = (cross(c, d, a), cross(c, d, b), cross(a, b, c), cross(a, b, d));+    ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0))+}++fn seg_seg(a: P, b: P, c: P, d: P) -> f64 {+    if segs_cross(a, b, c, d) {+        return 0.0;+    }+    seg_point(a, c, d).min(seg_point(b, c, d)).min(seg_point(c, a, b)).min(seg_point(d, a, b))+}++/// Ray casting over a closed polygon given as edges.+fn inside_edges(p: P, edges: &[(P, P)]) -> bool {+    let mut c = false;+    for (a, b) in edges {+        if (a.1 > p.1) != (b.1 > p.1) && p.0 < (b.0 - a.0) * (p.1 - a.1) / (b.1 - a.1) + a.0 {+            c = !c;+        }+    }+    c+}++/// The gap between two outlines given as edges: 0 when they touch, cross or nest.+pub fn edges_distance(a: &[(P, P)], b: &[(P, P)]) -> f64 {+    let mut best = f64::INFINITY;+    for (p, q) in a {+        for (r, s) in b {+            best = best.min(seg_seg(*p, *q, *r, *s));+            if best == 0.0 {+                return 0.0;+            }+        }+    }+    if let (Some(pa), Some(pb)) = (a.first(), b.first()) {+        if inside_edges(pa.0, b) || inside_edges(pb.0, a) {+            return 0.0;+        }+    }+    best+}++pub fn rect_distance(a: &Rect, b: &Rect) -> f64 {+    let dx = (b.min_x - a.max_x).max(a.min_x - b.max_x).max(0.0);+    let dy = (b.min_y - a.max_y).max(a.min_y - b.max_y).max(0.0);+    (dx * dx + dy * dy).sqrt()+}++fn rect_edges(r: &Rect) -> Vec<(P, P)> {+    let c = [(r.min_x, r.min_y), (r.max_x, r.min_y), (r.max_x, r.max_y), (r.min_x, r.max_y)];+    (0..4).map(|i| (c[i], c[(i + 1) % 4])).collect()+}++fn outline_of(p: &Placement) -> Vec<(P, P)> {+    if p.outline.is_empty() {+        rect_edges(&p.courtyard)+    } else {+        p.outline.clone()+    }+}++// ---------------------------------------------------------------------------+// Pad copper in board coordinates+// ---------------------------------------------------------------------------++/// One pad's copper: a circle, or the polygon of its rectangle (round-rect and oval pads+/// use their full rectangle, which is conservative: the reported gap is never larger than+/// KiCad's).+#[derive(Clone, Debug)]+pub struct PadShape {+    pub reference: String,+    pub pad: String,+    pub net: String,+    pub center: P,+    pub circle: Option<f64>,+    pub edges: Vec<(P, P)>,+    pub bbox: Rect,+    pub layers: Vec<String>,+}++pub fn pad_shapes(board: &Board) -> Vec<PadShape> {+    let mut out = Vec::new();+    for fp in &board.footprints {+        let (fx, fy, rot) = fp.position.as_ref().map(|p| (p.x, p.y, p.rotation.unwrap_or(0.0))).unwrap_or((0.0, 0.0, 0.0));+        for pad in &fp.pads {+            let (Some(px), Some(py)) = (pad.x, pad.y) else { continue };+            let (w, h) = (pad.width.unwrap_or(0.0), pad.height.unwrap_or(0.0));+            let center = placement::to_board((fx, fy), rot, (px, py));+            let ang = pad.rotation.unwrap_or(0.0);+            let circle = (pad.shape == "circle").then_some(w.max(h) / 2.0);+            let corners: Vec<P> = [(-w / 2.0, -h / 2.0), (w / 2.0, -h / 2.0), (w / 2.0, h / 2.0), (-w / 2.0, h / 2.0)].iter().map(|c| placement::to_board(center, ang, *c)).collect();+            let edges: Vec<(P, P)> = (0..4).map(|i| (corners[i], corners[(i + 1) % 4])).collect();+            let bbox = match circle {+                Some(r) => Rect { min_x: center.0 - r, min_y: center.1 - r, max_x: center.0 + r, max_y: center.1 + r },+                None => Rect::from_points(corners.iter().copied()).unwrap(),+            };+            out.push(PadShape { reference: fp.reference.clone(), pad: pad.number.clone(), net: pad.net_name.clone().unwrap_or_default(), center, circle, edges, bbox, layers: pad.layers.clone().unwrap_or_default() });+        }+    }+    out+}++fn shape_distance(a: &PadShape, b: &PadShape) -> f64 {+    match (a.circle, b.circle) {+        (Some(ra), Some(rb)) => (pcb::dist(a.center, b.center) - ra - rb).max(0.0),+        (Some(ra), None) => point_to_poly(a.center, &b.edges, ra),+        (None, Some(rb)) => point_to_poly(b.center, &a.edges, rb),+        (None, None) => edges_distance(&a.edges, &b.edges),+    }+}++fn point_to_poly(p: P, edges: &[(P, P)], r: f64) -> f64 {+    if inside_edges(p, edges) {+        return 0.0;+    }+    (edges.iter().map(|(a, b)| seg_point(p, *a, *b)).fold(f64::INFINITY, f64::min) - r).max(0.0)+}++fn copper_sides(layers: &[String]) -> (bool, bool) {+    let any = layers.iter().any(|l| l == "*.Cu");+    (any || layers.iter().any(|l| l == "F.Cu"), any || layers.iter().any(|l| l == "B.Cu"))+}++// ---------------------------------------------------------------------------+// kicad_check_clearance+// ---------------------------------------------------------------------------++fn refs_arg(args: &Value) -> Option<Vec<String>> {+    args.get("refs").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(|s| s.trim().to_uppercase()).collect())+}++/// Footprint pairs (`mode: "courtyard"`, the default) or pad pairs of different footprints+/// (`mode: "pads"`) closer than `threshold` mm (default 0.25 courtyard, 0.2 pads). `refs`+/// limits the check to pairs that involve at least one of them. Pads on the same net are+/// skipped unless `includeSameNet`. Courtyards on different sides never conflict.+pub fn check_clearance(board: &Board, args: &Value) -> R<Value> {+    let mode = args.get("mode").and_then(Value::as_str).unwrap_or("courtyard");+    let threshold = match args.get("threshold") {+        Some(v) => pcb::number(Some(v), "threshold", false)?.max(0.0),+        None => if mode == "pads" { 0.2 } else { 0.25 },+    };+    let limit = args.get("maxPairs").and_then(Value::as_u64).unwrap_or(200) as usize;+    let refs = refs_arg(args);+    if let Some(rs) = &refs {+        let known: Vec<String> = board.footprints.iter().map(|f| f.reference.to_uppercase()).collect();+        let unknown: Vec<&String> = rs.iter().filter(|r| !known.contains(r)).collect();+        if !unknown.is_empty() {+            return Err(RoutingError::new("unknown_footprint", format!("not on this board: {unknown:?}")));+        }+    }+    let wanted = |r: &str| refs.as_ref().map(|rs| rs.iter().any(|x| x == &r.to_uppercase())).unwrap_or(true);+    let mut pairs: Vec<Value> = Vec::new();+    let mut checked = 0usize;+    match mode {+        "courtyard" => {+            let places = placement::placements(board);+            for i in 0..places.len() {+                for j in (i + 1)..places.len() {+                    let (a, b) = (&places[i], &places[j]);+                    if a.side != b.side || !(wanted(&a.reference) || wanted(&b.reference)) {+                        continue;+                    }+                    checked += 1;+                    if rect_distance(&a.courtyard, &b.courtyard) > threshold {+                        continue;+                    }+                    let d = edges_distance(&outline_of(a), &outline_of(b));+                    if d < threshold {+                        let (x, y) = if placement::ref_key(&a.reference) <= placement::ref_key(&b.reference) { (a, b) } else { (b, a) };+                        pairs.push(json!({"a": x.reference, "b": y.reference, "side": a.side, "distanceMm": r3(d), "overlapping": d == 0.0,+                            "measuredOn": if a.outline.is_empty() || b.outline.is_empty() { "courtyard box" } else { "courtyard outline" }}));+                    }+                }+            }+        }+        "pads" => {+            let same_net = args.get("includeSameNet") == Some(&json!(true));+            let shapes = pad_shapes(board);+            for i in 0..shapes.len() {+                for j in (i + 1)..shapes.len() {+                    let (a, b) = (&shapes[i], &shapes[j]);+                    if a.reference == b.reference || !(wanted(&a.reference) || wanted(&b.reference)) {+                        continue;+                    }+                    if !same_net && !a.net.is_empty() && a.net == b.net {+                        continue;+                    }+                    let (af, ab) = copper_sides(&a.layers);+                    let (bf, bb) = copper_sides(&b.layers);+                    if !((af && bf) || (ab && bb)) {+                        continue;+                    }+                    checked += 1;+                    if rect_distance(&a.bbox, &b.bbox) > threshold {+                        continue;+                    }+                    let d = shape_distance(a, b);+                    if d < threshold {+                        pairs.push(json!({"a": format!("{}.{}", a.reference, a.pad), "b": format!("{}.{}", b.reference, b.pad), "netA": a.net, "netB": b.net, "distanceMm": r3(d), "touching": d == 0.0}));+                    }+                }+            }+        }+        other => return Err(RoutingError::new("invalid_mode", format!("mode is courtyard or pads, not {other}"))),+    }+    pairs.sort_by(|x, y| x["distanceMm"].as_f64().partial_cmp(&y["distanceMm"].as_f64()).unwrap_or(std::cmp::Ordering::Equal));+    let total = pairs.len();+    pairs.truncate(limit);+    Ok(json!({+        "mode": mode, "thresholdMm": threshold, "pairsChecked": checked, "violationCount": total, "pairs": pairs, "truncated": total > limit,+        "clean": total == 0,+        "method": if mode == "pads" { "pad copper: circles exact, rectangles, round-rects and ovals as their full rectangle (conservative)" } else { "courtyard outline when both footprints have one, else the courtyard box; same side only" },+    }))+}++// ---------------------------------------------------------------------------+// Part classification+// ---------------------------------------------------------------------------++fn prefix(r: &str) -> String {+    r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_uppercase()+}++pub fn is_ground(net: &str) -> bool {+    let n = net.trim_start_matches('/').to_uppercase();+    n.contains("GND") || n.starts_with("VSS") || n == "0V" || n.ends_with("_VSS")+}++pub fn is_connector(fp: &Footprint) -> bool {+    let p = prefix(&fp.reference);+    let lib = fp.footprint.to_lowercase();+    matches!(p.as_str(), "J" | "P" | "CN" | "X" | "USB" | "CON") || ["connector", "usb", "pinheader", "pin_header", "terminalblock", "jst", "molex", "barrel"].iter().any(|k| lib.contains(k))+}++pub fn is_ic(fp: &Footprint) -> bool {+    matches!(prefix(&fp.reference).as_str(), "U" | "IC") && fp.pads.len() >= 3+}++pub fn is_cap(fp: &Footprint) -> bool {+    prefix(&fp.reference) == "C" && fp.pads.len() == 2+}++/// A capacitor across a ground net and a power net that an IC also has: (power pad+/// number, power net).+pub fn decoupling_role(cap: &Footprint) -> Option<(String, String)> {+    if !is_cap(cap) {+        return None;+    }+    let nets: Vec<(String, String)> = cap.pads.iter().map(|p| (p.number.clone(), p.net_name.clone().unwrap_or_default())).collect();+    let g = nets.iter().filter(|(_, n)| is_ground(n)).count();+    if g != 1 {+        return None;+    }+    nets.into_iter().find(|(_, n)| !n.is_empty() && !is_ground(n))+}++// ---------------------------------------------------------------------------+// kicad_score_placement+// ---------------------------------------------------------------------------++/// 0-100 with named deductions. Each rule's points per offence and cap:+/// courtyard overlap 10 (40), outside the outline 10 (30), connector farther than+/// `connectorEdgeMm` (3) from the edge 5 (15), decoupling cap farther than+/// `decouplingMm` (3) from its IC's nearest pin on the cap's power net 3 (15), and the+/// ratsnest: the mean MST edge as a fraction of the board diagonal, 0 points at 10 %+/// rising to 20 at 40 %.+pub fn score(board: &Board, args: &Value) -> Value {+    let places = placement::placements(board);+    let outline = placement::outline(board);+    let edge_mm = args.get("connectorEdgeMm").and_then(Value::as_f64).unwrap_or(3.0);+    let decap_mm = args.get("decouplingMm").and_then(Value::as_f64).unwrap_or(3.0);+    let mut deductions = Vec::new();+    let mut push = |rule: &str, per: f64, cap: f64, items: Vec<Value>, detail: &str| {+        let pts = (per * items.len() as f64).min(cap);+        deductions.push(json!({"rule": rule, "count": items.len(), "points": pts, "perItem": per, "cap": cap, "items": items, "detail": detail}));+        pts+    };+    let mut total = 0.0;+    let overlaps = placement::overlaps(&places);+    total += push("courtyard_overlap", 10.0, 40.0, overlaps.iter().map(|o| json!({"a": o.a, "b": o.b, "side": o.side})).collect(), "same-side courtyards that overlap");+    let outside: Vec<Value> = places.iter().filter(|p| p.inside_outline == Some(false)).map(|p| json!(p.reference)).collect();+    total += push("outside_board", 10.0, 30.0, outside, "courtyard not inside the Edge.Cuts box");+    let mut far_conn = Vec::new();+    if let Some(o) = &outline {+        for (fp, p) in board.footprints.iter().zip(places.iter()) {+            if !is_connector(fp) || p.inside_outline == Some(false) {+                continue;+            }+            let c = &p.courtyard;+            let d = (c.min_x - o.min_x).min(o.max_x - c.max_x).min(c.min_y - o.min_y).min(o.max_y - c.max_y).max(0.0);+            if d > edge_mm {+                far_conn.push(json!({"ref": fp.reference, "edgeDistanceMm": r3(d)}));+            }+        }+    }+    total += push("connector_off_edge", 5.0, 15.0, far_conn, "connectors whose courtyard is farther than connectorEdgeMm from the board edge");+    let pads = pcb::pads(board);+    let mut far_caps = Vec::new();+    for fp in &board.footprints {+        let Some((pad_no, net)) = decoupling_role(fp) else { continue };+        let Some(cp) = pads.iter().find(|p| p.reference == fp.reference && p.pad == pad_no) else { continue };+        let ic_pins: Vec<&pcb::Pad> = pads.iter().filter(|p| p.net_name == net && p.reference != fp.reference && board.footprints.iter().any(|f| f.reference == p.reference && is_ic(f))).collect();+        let Some(best) = ic_pins.iter().min_by(|a, b| pcb::dist((a.x, a.y), (cp.x, cp.y)).partial_cmp(&pcb::dist((b.x, b.y), (cp.x, cp.y))).unwrap()) else { continue };+        let d = pcb::dist((best.x, best.y), (cp.x, cp.y));+        if d > decap_mm {+            far_caps.push(json!({"ref": fp.reference, "net": net, "nearestPin": best.name, "distanceMm": r3(d)}));+        }+    }+    total += push("decoupling_far", 3.0, 15.0, far_caps, "decoupling caps farther than decouplingMm from their IC's nearest pin on the same power net");+    let (nets, mst) = placement::nets(board);+    let edges: usize = nets.iter().map(|n| n.pad_count.saturating_sub(1)).sum();+    let diag = outline.map(|o| (o.width().powi(2) + o.height().powi(2)).sqrt()).unwrap_or(0.0);+    let frac = if edges > 0 && diag > 0.0 { mst / edges as f64 / diag } else { 0.0 };+    let rpts = (((frac - 0.1) / 0.3).clamp(0.0, 1.0) * 20.0 * 10.0).round() / 10.0;+    deductions.push(json!({"rule": "ratsnest_length", "count": edges, "points": rpts, "cap": 20.0, "items": [],+        "detail": format!("mean ratsnest edge {:.2} mm is {:.1} % of the {:.1} mm board diagonal (0 points at 10 %, 20 at 40 %)", if edges > 0 { mst / edges as f64 } else { 0.0 }, frac * 100.0, diag)}));+    total += rpts;+    let score = (100.0 - total).max(0.0).round();+    let grade = match score as i64 {+        90..=100 => "A",+        80..=89 => "B",+        70..=79 => "C",+        50..=69 => "D",+        _ => "F",+    };+    deductions.sort_by(|a, b| b["points"].as_f64().partial_cmp(&a["points"].as_f64()).unwrap_or(std::cmp::Ordering::Equal));+    json!({+        "score": score, "grade": grade, "deductions": deductions,+        "metrics": {"footprints": places.len(), "ratsnestTotalMm": mst, "ratsnestEdges": edges, "boardDiagonalMm": r3(diag), "outlineFound": outline.is_some()},+        "thresholds": {"connectorEdgeMm": edge_mm, "decouplingMm": decap_mm},+    })+}++// ---------------------------------------------------------------------------+// kicad_place_decoupling+// ---------------------------------------------------------------------------++fn side_normal(c: &Rect, pin: P) -> ((f64, f64), &'static str) {+    let d = [(pin.0 - c.min_x, (-1.0, 0.0), "left"), (c.max_x - pin.0, (1.0, 0.0), "right"), (pin.1 - c.min_y, (0.0, -1.0), "top"), (c.max_y - pin.1, (0.0, 1.0), "bottom")];+    let best = d.iter().min_by(|a, b| a.0.partial_cmp(&b.0).unwrap()).unwrap();+    (best.1, best.2)+}++/// Positions for capacitors beside an IC's power pins: each cap goes just outside the IC+/// courtyard (`gap` mm, default 0.3) on the side of the pin it serves, turned so its power+/// pad faces the pin and lined up with it; a spot taken by another courtyard slides along+/// the edge. `caps` defaults to every capacitor across ground and one of the IC's power+/// nets. The result carries `moveArgs`, the exact `kicad_move_footprint` batch, and+/// `validation` from the same checks that verb runs.+pub fn plan_decoupling(board: &Board, args: &Value) -> R<Value> {+    let ic_ref = args.get("ic").or_else(|| args.get("ref")).and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).ok_or_else(|| RoutingError::new("missing_ref", "Pass ic: the IC reference (e.g. \"U1\")"))?;+    let places = placement::placements(board);+    let idx = board.footprints.iter().position(|f| f.reference.eq_ignore_ascii_case(ic_ref)).ok_or_else(|| RoutingError::new("unknown_footprint", format!("No footprint {ic_ref}")))?;+    let ic = &board.footprints[idx];+    let icp = &places[idx];+    let gap = args.get("gap").and_then(Value::as_f64).unwrap_or(0.3).max(0.0);+    let pads = pcb::pads(board);+    let ic_pads: Vec<&pcb::Pad> = pads.iter().filter(|p| p.reference == ic.reference).collect();+    let power_nets: Vec<String> = {+        let mut v: Vec<String> = ic_pads.iter().map(|p| p.net_name.clone()).filter(|n| !n.is_empty() && !is_ground(n)).collect();+        v.sort();+        v.dedup();+        v+    };+    let cap_refs: Vec<String> = match args.get("caps").and_then(Value::as_array) {+        Some(a) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),+        None => {+            let mut v: Vec<String> = board.footprints.iter().filter(|f| decoupling_role(f).map(|(_, n)| power_nets.contains(&n)).unwrap_or(false)).map(|f| f.reference.clone()).collect();+            v.sort_by_key(|r| placement::ref_key(r));+            v+        }+    };+    if cap_refs.is_empty() {+        return Err(RoutingError::new("no_caps", format!("no capacitor across ground and a power net of {}; pass caps:[...]", ic.reference)).with("icPowerNets", json!(power_nets)));+    }+    let moving: Vec<String> = cap_refs.iter().map(|r| r.to_uppercase()).collect();+    // Obstacles: every same-side courtyard that is not one of the caps being placed.+    let mut taken: Vec<Rect> = places.iter().filter(|p| p.side == icp.side && !moving.contains(&p.reference.to_uppercase())).map(|p| p.courtyard).collect();+    let outline = placement::outline(board);+    let mut used_pins: Vec<String> = Vec::new();+    let mut moves = Vec::new();+    let mut rows = Vec::new();+    let mut skipped = Vec::new();+    for cr in &cap_refs {+        let Some(ci) = board.footprints.iter().position(|f| f.reference.eq_ignore_ascii_case(cr)) else {+            skipped.push(json!({"ref": cr, "reason": "unknown_footprint"}));+            continue;+        };+        let cap = &board.footprints[ci];+        if places[ci].side != icp.side {+            skipped.push(json!({"ref": cap.reference, "reason": "other_side", "detail": format!("{} is on {}, the IC on {}; flip it first (side changes are not moved by kicad_move_footprint)", cap.reference, places[ci].side, icp.side)}));+            continue;+        }+        let Some(pp) = cap.pads.iter().find(|p| p.net_name.as_deref().map(|n| power_nets.iter().any(|x| x == n)).unwrap_or(false)) else {+            skipped.push(json!({"ref": cap.reference, "reason": "no_shared_power_net"}));+            continue;+        };+        let net = pp.net_name.clone().unwrap_or_default();+        let mut pins: Vec<&&pcb::Pad> = ic_pads.iter().filter(|p| p.net_name == net).collect();+        pins.sort_by_key(|p| (used_pins.contains(&p.name), placement::ref_key(&p.pad)));+        let pin = pins[0];+        used_pins.push(pin.name.clone());+        let (n, side) = side_normal(&icp.courtyard, (pin.x, pin.y));+        let v = (pp.x.unwrap_or(0.0), pp.y.unwrap_or(0.0));+        let rot = [0.0, 90.0, 180.0, 270.0]+            .into_iter()+            .max_by(|a, b| {+                let ra = placement::to_board((0.0, 0.0), *a, v);+                let rb = placement::to_board((0.0, 0.0), *b, v);+                (-(ra.0 * n.0 + ra.1 * n.1)).partial_cmp(&(-(rb.0 * n.0 + rb.1 * n.1))).unwrap()+            })+            .unwrap();+        let rot = if rot == 270.0 { -90.0 } else { rot };+        let (cy, _) = placement::courtyard_at(cap, (0.0, 0.0), rot);+        let rv = placement::to_board((0.0, 0.0), rot, v);+        let c = &icp.courtyard;+        let base = match side {+            "left" => (c.min_x - gap - cy.max_x, pin.y - rv.1),+            "right" => (c.max_x + gap - cy.min_x, pin.y - rv.1),+            "top" => (pin.x - rv.0, c.min_y - gap - cy.max_y),+            _ => (pin.x - rv.0, c.max_y + gap - cy.min_y),+        };+        let along = if n.0 != 0.0 { (0.0, 1.0) } else { (1.0, 0.0) };+        let step = if n.0 != 0.0 { cy.height() } else { cy.width() } + gap;+        let mut chosen = None;+        for k in 0..13 {+            let s = if k == 0 { 0.0 } else if k % 2 == 1 { ((k + 1) / 2) as f64 * step } else { -((k / 2) as f64) * step };+            let at = (pcb::round6(base.0 + along.0 * s), pcb::round6(base.1 + along.1 * s));+            let r = placement::courtyard_at(cap, at, rot).0;+            let clear = taken.iter().all(|t| !t.overlaps(&r)) && outline.map(|o| o.contains(&r)).unwrap_or(true);+            if clear {+                chosen = Some((at, r, s));+                break;+            }+        }+        let Some((at, r, slid)) = chosen else {+            skipped.push(json!({"ref": cap.reference, "reason": "no_free_spot", "pin": pin.name, "side": side}));+            continue;+        };+        taken.push(r);+        let pad_at = placement::to_board(at, rot, v);+        rows.push(json!({"ref": cap.reference, "net": net, "pin": pin.name, "side": side, "x": at.0, "y": at.1, "rotation": rot,+            "powerPad": format!("{}.{}", cap.reference, pp.number), "padToPinMm": r3(pcb::dist(pad_at, (pin.x, pin.y))), "slidMm": r3(slid),+            "from": {"x": places[ci].x, "y": places[ci].y, "rotation": places[ci].rotation}}));+        moves.push(json!({"ref": cap.reference, "x": at.0, "y": at.1, "rotation": rot}));+    }+    let validation = if moves.is_empty() {+        json!({"ok": false, "reason": "nothing to place"})+    } else {+        match placement::plan_moves(board, &json!({"refs": moves})) {+            Ok(p) => json!({"ok": true, "checks": p.checks}),+            Err(e) => {+                let mut v = json!({"ok": false, "errorCode": e.code, "error": e.message});+                for (k, val) in e.detail {+                    v[k] = val;+                }+                v+            }+        }+    };+    Ok(json!({"ic": ic.reference, "icPowerNets": power_nets, "placements": rows, "skipped": skipped, "moveArgs": {"refs": moves}, "validation": validation, "gapMm": gap}))+}++// ---------------------------------------------------------------------------+// kicad_plan_fanout+// ---------------------------------------------------------------------------++fn median(mut v: Vec<f64>) -> Option<f64> {+    if v.is_empty() {+        return None;+    }+    v.sort_by(|a, b| a.partial_cmp(b).unwrap());+    Some(v[v.len() / 2])+}++/// A fanout plan for one BGA or perimeter (QFN, QFP, SOIC) package. Detects the pad pitch+/// (median nearest-neighbour spacing) and the package kind (pads inside the grid make a+/// BGA). BGA: the outer ring escapes on the surface; every inner ball gets a dogbone via+/// on the diagonal away from the package centre, or a via in the pad when the dogbone does+/// not fit (`style: auto|dogbone|via-in-pad`). Perimeter: each pad gets an outward surface+/// stub (with a via at its end when `viaPerimeter` or the net is ground), and an exposed+/// pad a via grid. `calls` is one `kicad_route_net` argument object per net.+pub fn plan_fanout(board: &Board, args: &Value) -> R<Value> {+    let r = args.get("ref").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).ok_or_else(|| RoutingError::new("missing_ref", "Pass ref: the BGA or QFN reference"))?;+    let fp = board.footprints.iter().find(|f| f.reference.eq_ignore_ascii_case(r)).ok_or_else(|| RoutingError::new("unknown_footprint", format!("No footprint {r}")))?;+    let side = if fp.layer.as_deref() == Some("B.Cu") { "B.Cu" } else { "F.Cu" };+    let copper = pcb::copper_layers(board);+    let escape = match args.get("escapeLayer").and_then(Value::as_str) {+        Some(l) if copper.iter().any(|c| c == l) => l.to_string(),+        Some(l) => return Err(RoutingError::new("unknown_layer", format!("Unknown copper layer: {l}")).with("copperLayers", json!(copper))),+        None => copper.iter().find(|l| l.starts_with("In")).cloned().unwrap_or_else(|| if side == "F.Cu" { "B.Cu".into() } else { "F.Cu".into() }),+    };+    let all = pcb::pads(board);+    let pads: Vec<&pcb::Pad> = all.iter().filter(|p| p.reference == fp.reference && !p.pad.is_empty() && p.kind == "smd").collect();+    if pads.len() < 4 {+        return Err(RoutingError::new("not_a_package", format!("{} has {} SMD pads; fanout is for BGA and QFN/QFP packages", fp.reference, pads.len())));+    }+    let sizes: Vec<(f64, f64)> = pads.iter().map(|p| fp.pads.iter().find(|q| q.number == p.pad).map(|q| (q.width.unwrap_or(0.0), q.height.unwrap_or(0.0))).unwrap_or((0.0, 0.0))).collect();+    let areas: Vec<f64> = sizes.iter().map(|s| s.0 * s.1).collect();+    let med_area = median(areas.clone()).unwrap_or(0.0);+    let thermal: Vec<usize> = (0..pads.len()).filter(|i| areas[*i] > 4.0 * med_area).collect();+    let signal: Vec<usize> = (0..pads.len()).filter(|i| !thermal.contains(i)).collect();+    let nn: Vec<f64> = signal.iter().map(|&i| signal.iter().filter(|&&j| j != i).map(|&j| pcb::dist((pads[i].x, pads[i].y), (pads[j].x, pads[j].y))).fold(f64::INFINITY, f64::min)).collect();+    let pitch = match args.get("pitch") {+        Some(v) => pcb::number(Some(v), "pitch", true)?,+        None => r3(median(nn).ok_or_else(|| RoutingError::new("not_a_package", "no pad pitch"))?),+    };+    let fx = fp.position.as_ref().map(|p| (p.x, p.y)).unwrap_or((0.0, 0.0));+    let cx = signal.iter().map(|&i| pads[i].x).sum::<f64>() / signal.len() as f64;+    let cy = signal.iter().map(|&i| pads[i].y).sum::<f64>() / signal.len() as f64;+    let (minx, maxx) = signal.iter().map(|&i| pads[i].x).fold((f64::INFINITY, f64::NEG_INFINITY), |a, x| (a.0.min(x), a.1.max(x)));+    let (miny, maxy) = signal.iter().map(|&i| pads[i].y).fold((f64::INFINITY, f64::NEG_INFINITY), |a, y| (a.0.min(y), a.1.max(y)));+    let interior = signal.iter().filter(|&&i| {+        let p = pads[i];+        (p.x - minx).min(maxx - p.x).min(p.y - miny).min(maxy - p.y) > pitch * 0.5+    }).count();+    let kind = if interior > 0 { "bga" } else { "perimeter" };+    let fine = pitch < 0.8 - 1e-9;+    let via_size = args.get("viaSize").and_then(Value::as_f64).unwrap_or(if fine { 0.4 } else if pitch < 1.0 - 1e-9 { 0.45 } else { 0.6 });+    let via_drill = args.get("viaDrill").and_then(Value::as_f64).unwrap_or(if via_size <= 0.45 { 0.2 } else { 0.3 });+    if via_drill >= via_size {+        return Err(RoutingError::new("invalid_geometry", "viaDrill must be smaller than viaSize"));+    }+    let width = args.get("width").and_then(Value::as_f64).unwrap_or(if fine { 0.1 } else { 0.15 });+    let clearance = args.get("clearance").and_then(Value::as_f64).unwrap_or(0.1);+    let pad_d = median(signal.iter().map(|&i| sizes[i].0.max(sizes[i].1)).collect()).unwrap_or(0.0);+    let dogbone_room = r3(pitch / 2f64.sqrt() - pad_d / 2.0 - via_size / 2.0);+    let dogbone_fits = dogbone_room + 1e-9 >= clearance;+    let want = args.get("style").and_then(Value::as_str).unwrap_or("auto");+    let style = match want {+        "auto" => if dogbone_fits { "dogbone" } else { "via-in-pad" },+        "dogbone" | "via-in-pad" => want,+        o => return Err(RoutingError::new("invalid_style", format!("style is auto, dogbone or via-in-pad, not {o}"))),+    };+    let mut warnings = Vec::new();+    if style == "dogbone" && !dogbone_fits {+        warnings.push(format!("a {via_size} mm dogbone via leaves {dogbone_room} mm to the balls at {pitch} mm pitch, under the {clearance} mm clearance; use via-in-pad or a smaller via"));+    }+    let via_perimeter = args.get("viaPerimeter") == Some(&json!(true));+    let stub = args.get("stubMm").and_then(Value::as_f64).unwrap_or(0.4);+    let mut rows = Vec::new();+    let mut calls: std::collections::BTreeMap<String, Vec<Value>> = std::collections::BTreeMap::new();+    let mut add = |net: &str, path: Value| calls.entry(net.to_string()).or_default().push(path);+    let pt = |x: f64, y: f64| json!([pcb::round6(x), pcb::round6(y)]);+    let pt_l = |x: f64, y: f64, l: &str| json!({"x": pcb::round6(x), "y": pcb::round6(y), "layer": l});+    let mut vias: Vec<P> = Vec::new();+    let sign = |v: f64| if v < -1e-9 { -1.0 } else { 1.0 };+    for &i in &signal {+        let p = pads[i];+        if p.net_name.is_empty() {+            rows.push(json!({"pad": p.name, "net": "", "style": "skip", "reason": "no net"}));+            continue;+        }+        if kind == "bga" {+            let ring = ((p.x - minx) / pitch).round().min(((maxx - p.x) / pitch).round()).min(((p.y - miny) / pitch).round()).min(((maxy - p.y) / pitch).round()) as i64;+            if ring == 0 && args.get("surfaceOuterRing") != Some(&json!(false)) {+                let (n, _) = side_normal(&Rect { min_x: minx, min_y: miny, max_x: maxx, max_y: maxy }, (p.x, p.y));+                let end = (p.x + n.0 * pitch, p.y + n.1 * pitch);+                rows.push(json!({"pad": p.name, "net": p.net_name, "ring": ring, "style": "surface", "escapeLayer": side, "stubEnd": pt(end.0, end.1)}));+                add(&p.net_name, json!([p.name, pt(end.0, end.1)]));+                continue;+            }+            if style == "dogbone" {+                let v = (p.x + sign(p.x - cx) * pitch / 2.0, p.y + sign(p.y - cy) * pitch / 2.0);+                vias.push(v);+                rows.push(json!({"pad": p.name, "net": p.net_name, "ring": ring, "style": "dogbone", "escapeLayer": escape, "via": {"at": pt(v.0, v.1), "size": via_size, "drill": via_drill}}));+                add(&p.net_name, json!([p.name, pt_l(v.0, v.1, &escape)]));+            } else {+                vias.push((p.x, p.y));+                rows.push(json!({"pad": p.name, "net": p.net_name, "ring": ring, "style": "via-in-pad", "escapeLayer": escape, "via": {"at": pt(p.x, p.y), "size": via_size, "drill": via_drill}, "note": "via-in-pad needs filled and capped vias (IPC-4761 type VII) from the fab"}));+                add(&p.net_name, json!([pt_l(p.x, p.y, side), pt_l(p.x, p.y, &escape)]));+            }+        } else {+            let (n, edge) = side_normal(&Rect { min_x: minx, min_y: miny, max_x: maxx, max_y: maxy }, (p.x, p.y));+            let (w, h) = sizes[i];+            let len_along = {+                let a = placement::to_board((0.0, 0.0), fp.pads.iter().find(|q| q.number == p.pad).and_then(|q| q.rotation).unwrap_or(0.0), (w, h));+                if n.0 != 0.0 { a.0.abs() } else { a.1.abs() }+            };+            let reach = len_along / 2.0 + stub + if via_perimeter || is_ground(&p.net_name) { via_size / 2.0 } else { 0.0 };+            let end = (p.x + n.0 * reach, p.y + n.1 * reach);+            if via_perimeter || is_ground(&p.net_name) {+                vias.push(end);+                rows.push(json!({"pad": p.name, "net": p.net_name, "edge": edge, "style": "stub+via", "escapeLayer": escape, "via": {"at": pt(end.0, end.1), "size": via_size, "drill": via_drill}}));+                add(&p.net_name, json!([p.name, pt_l(end.0, end.1, &escape)]));+            } else {+                rows.push(json!({"pad": p.name, "net": p.net_name, "edge": edge, "style": "surface", "escapeLayer": side, "stubEnd": pt(end.0, end.1)}));+                add(&p.net_name, json!([p.name, pt(end.0, end.1)]));+            }+        }+    }+    for &i in &thermal {+        let p = pads[i];+        if p.net_name.is_empty() {+            continue;+        }+        let (w, h) = sizes[i];+        let vp = (via_size + 0.5).max(1.0);+        let nx = (((w - via_size) / vp).floor() as i64 + 1).max(1);+        let ny = (((h - via_size) / vp).floor() as i64 + 1).max(1);+        let mut at = Vec::new();+        for a in 0..nx {+            for b in 0..ny {+                let v = (p.x + (a as f64 - (nx - 1) as f64 / 2.0) * vp, p.y + (b as f64 - (ny - 1) as f64 / 2.0) * vp);+                vias.push(v);+                at.push(pt(v.0, v.1));+                add(&p.net_name, json!([pt_l(v.0, v.1, side), pt_l(v.0, v.1, &escape)]));+            }+        }+        rows.push(json!({"pad": p.name, "net": p.net_name, "style": "thermal-via-grid", "escapeLayer": escape, "vias": at, "viaPitchMm": vp}));+    }+    // via to foreign copper: the nearest pad of another net, centre to edge (circles exact, rectangles by their circumcircle)+    let mut worst = f64::INFINITY;+    for v in &vias {+        for p in &pads {+            let (w, h) = fp.pads.iter().find(|q| q.number == p.pad).map(|q| (q.width.unwrap_or(0.0), q.height.unwrap_or(0.0))).unwrap_or((0.0, 0.0));+            let d = pcb::dist(*v, (p.x, p.y));+            if d < 1e-6 {+                continue;+            }+            worst = worst.min(d - w.max(h) / 2.0 - via_size / 2.0);+        }+    }+    let calls: Vec<Value> = calls.into_iter().map(|(net, paths)| json!({"net": net, "paths": paths, "width": width, "viaSize": via_size, "viaDrill": via_drill})).collect();+    let _ = fx;+    Ok(json!({+        "ref": fp.reference, "package": kind, "padCount": pads.len(), "pitchMm": pitch, "side": side, "escapeLayer": escape,+        "style": style, "requestedStyle": want, "dogboneRoomMm": dogbone_room, "dogboneFits": dogbone_fits,+        "via": {"size": via_size, "drill": via_drill}, "width": width, "clearanceMm": clearance,+        "pads": rows, "calls": calls, "callCount": calls.len(), "viaCount": vias.len(),+        "minViaToPadMm": if worst.is_finite() { json!(r3(worst)) } else { Value::Null },+        "warnings": warnings,+    }))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn distances_between_outlines() {+        let a = rect_edges(&Rect { min_x: 0.0, min_y: 0.0, max_x: 1.0, max_y: 1.0 });+        let b = rect_edges(&Rect { min_x: 1.5, min_y: 0.0, max_x: 2.0, max_y: 1.0 });+        assert!((edges_distance(&a, &b) - 0.5).abs() < 1e-9);+        let c = rect_edges(&Rect { min_x: 0.2, min_y: 0.2, max_x: 0.4, max_y: 0.4 });+        assert_eq!(edges_distance(&a, &c), 0.0, "nested");+        assert!(is_ground("/GND") && is_ground("VSSA") && !is_ground("+3V3"));+    }+}
rust/crates/kicad-core/src/project.rsadded+646
@@ -0,0 +1,646 @@+//! KiCad projects as files: create (KiCad 10 .kicad_pro, blank .kicad_sch and .kicad_pcb),+//! describe, rename (the files and the references inside them) and snapshot. The verb layer+//! adds the PDFs through kicad-cli and the "is KiCad holding it" refusal.++use std::collections::BTreeMap;+use std::path::{Path, PathBuf};++use serde_json::{json, Map, Value};++use crate::libraries::write_atomic;+use crate::schematic::{new_uuid, quote, Document, Kind, Node};++/// KiCad 10 file format versions.+pub const SCH_VERSION: &str = "20260306";+pub const PCB_VERSION: &str = "20260206";+const BLANK_PCB: &str = include_str!("../../../../templates/blank-board.kicad_pcb");++fn n(p: &Path) -> String {+    p.to_string_lossy().replace('\\', "/")+}++fn err(code: &str, msg: impl Into<String>, hint: impl Into<String>) -> Value {+    json!({"success": false, "errorCode": code, "error": msg.into(), "_hint": hint.into()})+}++/// A project name KiCad and every OS accept as a file stem.+pub fn valid_name(name: &str) -> bool {+    !name.is_empty() && name.len() <= 120 && !name.starts_with('.') && !name.ends_with('.') && !name.ends_with(' ') && name.chars().all(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') && !c.is_control())+}++// ---------------------------------------------------------------------------+// Create+// ---------------------------------------------------------------------------++/// The .kicad_pro KiCad 10 writes for a new project, trimmed to the keys that matter;+/// KiCad fills every other setting with its defaults on first open.+pub fn project_json(name: &str, root_uuid: &str, text_vars: &BTreeMap<String, String>) -> String {+    let vars: Map<String, Value> = text_vars.iter().map(|(k, v)| (k.clone(), json!(v))).collect();+    let doc = json!({+        "board": {"design_settings": {"defaults": {}, "rules": {}, "track_widths": [], "via_dimensions": []}},+        "meta": {"filename": format!("{name}.kicad_pro"), "version": 3},+        "net_settings": {+            "classes": [{+                "bus_width": 12, "clearance": 0.2, "diff_pair_gap": 0.25, "diff_pair_via_gap": 0.25, "diff_pair_width": 0.2,+                "line_style": 0, "microvia_diameter": 0.3, "microvia_drill": 0.1, "name": "Default", "pcb_color": "rgba(0, 0, 0, 0.000)",+                "priority": 2147483647, "schematic_color": "rgba(0, 0, 0, 0.000)", "track_width": 0.2, "via_diameter": 0.6, "via_drill": 0.3, "wire_width": 6+            }],+            "meta": {"version": 5}, "net_colors": null, "netclass_assignments": null, "netclass_patterns": []+        },+        "pcbnew": {"last_paths": {"gencad": "", "idf": "", "netlist": "", "plot": "", "pos_files": "", "specctra_dsn": "", "step": "", "svg": "", "vrml": ""}, "page_layout_descr_file": ""},+        "schematic": {"legacy_lib_dir": "", "legacy_lib_list": [], "top_level_sheets": [{"filename": format!("{name}.kicad_sch"), "name": name, "uuid": root_uuid}]},+        "sheets": [[root_uuid, "Root"]],+        "text_variables": vars,+    });+    serde_json::to_string_pretty(&doc).unwrap_or_default() + "\n"+}++/// A blank KiCad 10 schematic with an optional title. Returns (text, root uuid).+pub fn blank_schematic(title: Option<&str>) -> (String, String) {+    let uuid = new_uuid();+    let tb = title.map(|t| format!("\t(title_block\n\t\t(title {})\n\t)\n", quote(t))).unwrap_or_default();+    let text = format!(+        "(kicad_sch\n\t(version {SCH_VERSION})\n\t(generator \"eeschema\")\n\t(generator_version \"10.0\")\n\t(uuid \"{uuid}\")\n\t(paper \"A4\")\n{tb}\t(lib_symbols)\n\t(sheet_instances\n\t\t(path \"/\"\n\t\t\t(page \"1\")\n\t\t)\n\t)\n\t(embedded_fonts no)\n)\n"+    );+    (text, uuid)+}++/// The blank KiCad 10 board (templates/blank-board.kicad_pcb) with `layers` copper layers+/// (2 to 32, even) and an optional title block.+pub fn blank_board(title: Option<&str>, layers: u32) -> Result<String, String> {+    if !(2..=32).contains(&layers) || layers % 2 != 0 {+        return Err(format!("layers must be an even number from 2 to 32 (got {layers})"));+    }+    let mut text = BLANK_PCB.replace("\r\n", "\n");+    let anchor = "\t\t(0 \"F.Cu\" signal)\n";+    if !text.contains(anchor) {+        return Err("the blank board template has no F.Cu layer line".into());+    }+    let inner: String = (1..layers - 1).map(|k| format!("\t\t({} \"In{k}.Cu\" signal)\n", 2 + 2 * k)).collect();+    text = text.replacen(anchor, &format!("{anchor}{inner}"), 1);+    if let Some(t) = title {+        let paper = "\t(paper \"A4\")\n";+        text = text.replacen(paper, &format!("{paper}\t(title_block\n\t\t(title {})\n\t)\n", quote(t)), 1);+    }+    Ok(text)+}++pub struct CreateOpts<'a> {+    pub title: Option<&'a str>,+    pub layers: u32,+    pub fab_rules: Option<&'a str>,+    pub text_vars: BTreeMap<String, String>,+    pub dry_run: bool,+}++/// Write `<parent>/<name>/{name}.kicad_pro|.kicad_sch|.kicad_pcb`. Refuses a folder that+/// already holds a .kicad_pro. Every file is parsed again before anything is written.+pub fn create(parent: &Path, name: &str, o: &CreateOpts) -> Result<Value, Value> {+    if !valid_name(name) {+        return Err(err("bad_arg", format!("not a usable project name: {name:?}"), "Use letters, digits, spaces, '-', '_' or '.'; no path separators."));+    }+    let dir = parent.join(name);+    if dir.is_dir() {+        let existing: Vec<String> = std::fs::read_dir(&dir).map(|rd| rd.flatten().map(|e| e.file_name().to_string_lossy().to_string()).filter(|f| f.ends_with(".kicad_pro")).collect()).unwrap_or_default();+        if !existing.is_empty() {+            return Err(err("project_exists", format!("{} already holds {}", n(&dir), existing.join(", ")), "Pick another name or folder; kicad_project_info reads the existing project."));+        }+    }+    let mut vars = o.text_vars.clone();+    if let Some(r) = o.fab_rules {+        vars.insert("FAB_RULES".into(), r.to_string());+    }+    let (sch, root) = blank_schematic(o.title);+    let pcb = blank_board(o.title, o.layers).map_err(|e| err("bad_arg", e, "Pass layers: 2, 4, 6, ..."))?;+    let pro = project_json(name, &root, &vars);+    // Round-trip with the project's own parsers before writing anything.+    Document::parse(&sch).map_err(|e| err("internal", format!("generated schematic does not parse: {e}"), "File a bug."))?;+    let b = crate::pcb::parse_pcb_text(&pcb, "new").map_err(|e| err("internal", format!("generated board does not parse: {e}"), "File a bug."))?;+    if crate::pcb::copper_layers(&b).len() as u32 != o.layers {+        return Err(err("internal", "generated board has the wrong copper count", "File a bug."));+    }+    serde_json::from_str::<Value>(&pro).map_err(|e| err("internal", format!("generated project is not JSON: {e}"), "File a bug."))?;+    let files = [(format!("{name}.kicad_pro"), pro), (format!("{name}.kicad_sch"), sch), (format!("{name}.kicad_pcb"), pcb)];+    let paths: Vec<String> = files.iter().map(|(f, _)| n(&dir.join(f))).collect();+    if !o.dry_run {+        std::fs::create_dir_all(&dir).map_err(|e| err("write_failed", format!("cannot create {}: {e}", n(&dir)), "Pass a parent folder the desktop user can write."))?;+        for (f, text) in &files {+            write_atomic(&dir.join(f), text.as_bytes()).map_err(|e| err("write_failed", e, "Check permissions on the folder."))?;+        }+    }+    Ok(json!({"projectDir": n(&dir), "files": paths, "rootSheetUuid": root, "copperLayers": o.layers, "textVariables": vars, "dryRun": o.dry_run}))+}++// ---------------------------------------------------------------------------+// Locate and describe+// ---------------------------------------------------------------------------++/// The .kicad_pro for a path that is the project file, one of its documents, or its folder.+pub fn resolve_pro(path: &Path) -> Result<PathBuf, Value> {+    if path.is_dir() {+        let mut pros: Vec<PathBuf> = std::fs::read_dir(path).map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "kicad_pro").unwrap_or(false)).collect()).unwrap_or_default();+        pros.sort();+        return match pros.len() {+            1 => Ok(pros.remove(0)),+            0 => Err(err("project_not_found", format!("no .kicad_pro in {}", n(path)), "Pass the .kicad_pro itself, or kicad_project_create to make one.")),+            _ => Err(err("ambiguous_project", format!("{} projects in {}", pros.len(), n(path)), format!("Pass one of: {}", pros.iter().map(|p| n(p)).collect::<Vec<_>>().join(", ")))),+        };+    }+    let ext = path.extension().map(|x| x.to_string_lossy().to_lowercase()).unwrap_or_default();+    let pro = if ext == "kicad_pro" { path.to_path_buf() } else { path.with_extension("kicad_pro") };+    if pro.is_file() {+        Ok(pro)+    } else {+        Err(err("project_not_found", format!("project file not found: {}", n(&pro)), "Pass the absolute path of the .kicad_pro (or its folder)."))+    }+}++fn iso(t: std::time::SystemTime) -> String {+    let secs = t.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;+    let days = secs.div_euclid(86400);+    let rem = secs.rem_euclid(86400);+    let z = days + 719468;+    let era = z.div_euclid(146097);+    let doe = z.rem_euclid(146097);+    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);+    let mp = (5 * doy + 2) / 153;+    let d = doy - (153 * mp + 2) / 5 + 1;+    let mo = if mp < 10 { mp + 3 } else { mp - 9 };+    let y = yoe + era * 400 + if mo <= 2 { 1 } else { 0 };+    format!("{y:04}-{mo:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)+}++/// The child sheets a schematic references: (sheet name, file name).+pub fn child_sheets(text: &str) -> Vec<(String, String)> {+    let Ok(doc) = Document::parse(text) else { return Vec::new() };+    doc.root+        .children()+        .iter()+        .filter(|c| c.head() == Some("sheet"))+        .filter_map(|s| {+            let prop = |k: &str| s.children().iter().find(|p| p.head() == Some("property") && p.arg(0).as_deref() == Some(k)).and_then(|p| p.arg(1));+            let file = prop("Sheetfile").or_else(|| prop("Sheet file"))?;+            Some((prop("Sheetname").or_else(|| prop("Sheet name")).unwrap_or_default(), file))+        })+        .collect()+}++/// kicad_project_info: name, files, sheet tree, board summary, text variables, net classes.+pub fn info(pro: &Path) -> Result<Value, Value> {+    let dir = pro.parent().unwrap_or(Path::new("."));+    let name = pro.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    let text = std::fs::read_to_string(pro).map_err(|e| err("read_failed", format!("cannot read {}: {e}", n(pro)), "Check the path."))?;+    let doc: Value = serde_json::from_str(&text).map_err(|e| err("bad_project", format!("{} is not JSON: {e}", n(pro)), "A KiCad 5 .pro is not supported; open it in KiCad 10 once to convert."))?;+    let mut files: Vec<Value> = Vec::new();+    let mut latest: Option<std::time::SystemTime> = None;+    let mut entries: Vec<_> = std::fs::read_dir(dir).map(|rd| rd.flatten().collect()).unwrap_or_default();+    entries.sort_by_key(|e| e.file_name());+    for e in entries {+        let p = e.path();+        let fname = e.file_name().to_string_lossy().to_string();+        let is_kicad = fname.ends_with(".kicad_pro") || fname.ends_with(".kicad_sch") || fname.ends_with(".kicad_pcb") || fname.ends_with(".kicad_prl") || fname.ends_with(".kicad_dru") || fname.ends_with(".kicad_sym") || fname == "sym-lib-table" || fname == "fp-lib-table" || fname.ends_with(".pretty") || fname.ends_with(".kicad_jobset");+        if !is_kicad {+            continue;+        }+        let m = e.metadata().ok();+        let modified = m.as_ref().and_then(|m| m.modified().ok());+        if let Some(t) = modified {+            if latest.map(|l| t > l).unwrap_or(true) {+                latest = Some(t);+            }+        }+        files.push(json!({"name": fname, "path": n(&p), "bytes": m.as_ref().filter(|m| m.is_file()).map(|m| m.len()), "dir": p.is_dir(), "modified": modified.map(iso)}));+    }+    // Sheet tree from the root schematic, depth-first, cycles cut.+    let root_file = doc.pointer("/schematic/top_level_sheets/0/filename").and_then(Value::as_str).map(str::to_string).unwrap_or(format!("{name}.kicad_sch"));+    let mut sheets: Vec<Value> = Vec::new();+    let mut stack: Vec<(String, String, usize)> = vec![("Root".into(), root_file.clone(), 0)];+    let mut seen = std::collections::BTreeSet::new();+    while let Some((sname, file, depth)) = stack.pop() {+        let path = dir.join(&file);+        let exists = path.is_file();+        sheets.push(json!({"name": sname, "file": file, "depth": depth, "exists": exists}));+        if !exists || !seen.insert(file.clone()) || depth > 32 {+            continue;+        }+        if let Ok(t) = std::fs::read_to_string(&path) {+            for (cn, cf) in child_sheets(&t).into_iter().rev() {+                stack.push((cn, cf, depth + 1));+            }+        }+    }+    let board_path = dir.join(format!("{name}.kicad_pcb"));+    let board = if board_path.is_file() {+        match crate::pcb::parse_pcb(&board_path) {+            Ok(b) => json!({+                "file": n(&board_path), "copperLayers": crate::pcb::copper_layers(&b), "footprints": b.footprints.len(),+                "nets": b.nets.iter().filter(|x| !x.name.is_empty()).count(), "tracks": b.segments.len(), "vias": b.vias.len(), "zones": b.zones.len(),+                "size": b.dimensions.as_ref().map(|d| json!({"widthMm": d.width, "heightMm": d.height})), "version": b.version,+            }),+            Err(e) => json!({"file": n(&board_path), "error": e}),+        }+    } else {+        Value::Null+    };+    let classes: Vec<Value> = doc.pointer("/net_settings/classes").and_then(Value::as_array).map(|a| a.iter().map(|c| json!({"name": c.get("name"), "clearance": c.get("clearance"), "trackWidth": c.get("track_width"), "viaDiameter": c.get("via_diameter"), "viaDrill": c.get("via_drill")})).collect()).unwrap_or_default();+    let assignments = doc.pointer("/net_settings/netclass_patterns").and_then(Value::as_array).map(|a| a.len()).unwrap_or(0);+    Ok(json!({+        "name": name,+        "projectFile": n(pro),+        "projectDir": n(dir),+        "files": files,+        "rootSchematic": root_file,+        "sheets": sheets,+        "board": board,+        "textVariables": doc.get("text_variables").cloned().unwrap_or(json!({})),+        "netClasses": classes,+        "netClassPatterns": assignments,+        "lastModified": latest.map(iso),+        "locks": open_locks(dir),+    }))+}++/// KiCad's lock files in a project folder (`~<file>.lck`): any of them means an editor+/// holds a document of this project.+pub fn open_locks(dir: &Path) -> Vec<String> {+    let mut out: Vec<String> = std::fs::read_dir(dir)+        .map(|rd| rd.flatten().map(|e| e.file_name().to_string_lossy().to_string()).filter(|f| f.starts_with('~') && f.ends_with(".lck")).collect())+        .unwrap_or_default();+    out.sort();+    out+}++// ---------------------------------------------------------------------------+// Rename+// ---------------------------------------------------------------------------++#[derive(Debug, Default)]+pub struct RenamePlan {+    /// (new path, new content) written before the old files are removed.+    pub writes: Vec<(PathBuf, String)>,+    pub removes: Vec<PathBuf>,+    pub moves: Vec<(PathBuf, PathBuf)>,+    pub changes: Vec<String>,+}++impl RenamePlan {+    pub fn public(&self) -> Value {+        json!({+            "writes": self.writes.iter().map(|(p, _)| n(p)).collect::<Vec<_>>(),+            "removes": self.removes.iter().map(|p| n(p)).collect::<Vec<_>>(),+            "moves": self.moves.iter().map(|(a, b)| json!({"from": n(a), "to": n(b)})).collect::<Vec<_>>(),+            "changes": self.changes,+        })+    }+}++/// Replace, in a schematic, every `(project "old" ...)` instance header with the new name.+fn rename_in_schematic(text: &str, old: &str, new: &str) -> Result<(String, usize), String> {+    let mut doc = Document::parse(text)?;+    fn walk(node: &mut Node, old: &str, new: &str, count: &mut usize) {+        if node.head() == Some("project") && node.arg(0).as_deref() == Some(old) {+            node.set_arg(0, Node::quoted(new));+            *count += 1;+        }+        if let Kind::List { children, .. } = &mut node.kind {+            for c in children.iter_mut() {+                walk(c, old, new, count);+            }+        }+    }+    let mut count = 0;+    walk(&mut doc.root, old, new, &mut count);+    Ok((doc.to_text(), count))+}++/// In a board: footprints' `(sheetfile "old.kicad_sch")` and a title equal to the old name.+fn rename_in_board(text: &str, old: &str, new: &str) -> Result<(String, usize), String> {+    let mut doc = Document::parse(text)?;+    let old_sch = format!("{old}.kicad_sch");+    let new_sch = format!("{new}.kicad_sch");+    let mut count = 0;+    if let Some(children) = doc.root.children_mut() {+        for c in children.iter_mut() {+            match c.head() {+                Some("footprint") => {+                    if let Some(sf) = c.child_mut("sheetfile") {+                        if sf.arg(0).as_deref() == Some(old_sch.as_str()) {+                            sf.set_arg(0, Node::quoted(&new_sch));+                            count += 1;+                        }+                    }+                }+                Some("title_block") => {+                    if let Some(t) = c.child_mut("title") {+                        if t.arg(0).as_deref() == Some(old) {+                            t.set_arg(0, Node::quoted(new));+                            count += 1;+                        }+                    }+                }+                _ => {}+            }+        }+    }+    let out = doc.to_text();+    crate::pcb::parse_pcb_text(&out, "renamed").map_err(|e| format!("renamed board does not parse: {e}"))?;+    Ok((out, count))+}++/// Plan renaming the project at `pro` to `new`: the four files (and .kicad_dru, the+/// backups folder) get the new stem; the project JSON, every schematic's instance headers+/// and the board's sheet references follow. Nothing is touched until `apply`.+pub fn rename_plan(pro: &Path, new: &str) -> Result<RenamePlan, Value> {+    if !valid_name(new) {+        return Err(err("bad_arg", format!("not a usable project name: {new:?}"), "Use letters, digits, spaces, '-', '_' or '.'."));+    }+    let dir = pro.parent().unwrap_or(Path::new("."));+    let old = pro.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();+    if old == new {+        return Err(err("bad_arg", "the new name is the current name", "Pass a different newName."));+    }+    let mut plan = RenamePlan::default();+    let target = |ext: &str| dir.join(format!("{new}.{ext}"));+    for ext in ["kicad_pro", "kicad_sch", "kicad_pcb", "kicad_prl", "kicad_dru"] {+        let src = dir.join(format!("{old}.{ext}"));+        if src.is_file() && target(ext).exists() {+            return Err(err("target_exists", format!("{} already exists", n(&target(ext))), "Pick a name no file in the folder uses."));+        }+    }+    // Project JSON.+    let pro_text = std::fs::read_to_string(pro).map_err(|e| err("read_failed", e.to_string(), "Check the path."))?;+    let mut doc: Value = serde_json::from_str(&pro_text).map_err(|e| err("bad_project", format!("not JSON: {e}"), "Open the project in KiCad 10 once."))?;+    doc["meta"]["filename"] = json!(format!("{new}.kicad_pro"));+    if let Some(sheets) = doc.pointer_mut("/schematic/top_level_sheets").and_then(Value::as_array_mut) {+        for s in sheets {+            if s.get("filename").and_then(Value::as_str) == Some(format!("{old}.kicad_sch").as_str()) {+                s["filename"] = json!(format!("{new}.kicad_sch"));+                if s.get("name").and_then(Value::as_str) == Some(old.as_str()) {+                    s["name"] = json!(new);+                }+            }+        }+    }+    plan.writes.push((target("kicad_pro"), serde_json::to_string_pretty(&doc).unwrap_or_default() + "\n"));+    plan.removes.push(pro.to_path_buf());+    plan.changes.push("project meta.filename and the top-level sheet entry".into());+    // Local settings.+    let prl = dir.join(format!("{old}.kicad_prl"));+    if prl.is_file() {+        let t = std::fs::read_to_string(&prl).unwrap_or_default();+        let new_text = match serde_json::from_str::<Value>(&t) {+            Ok(mut v) => {+                if v.get("meta").is_some() {+                    v["meta"]["filename"] = json!(format!("{new}.kicad_prl"));+                }+                serde_json::to_string_pretty(&v).unwrap_or(t) + "\n"+            }+            Err(_) => t,+        };+        plan.writes.push((target("kicad_prl"), new_text));+        plan.removes.push(prl);+    }+    // Every schematic in the folder: instance headers name the project.+    let mut schs: Vec<PathBuf> = std::fs::read_dir(dir).map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.extension().map(|x| x == "kicad_sch").unwrap_or(false)).collect()).unwrap_or_default();+    schs.sort();+    for sch in schs {+        let t = std::fs::read_to_string(&sch).map_err(|e| err("read_failed", e.to_string(), "Check permissions."))?;+        let (out, count) = rename_in_schematic(&t, &old, new).map_err(|e| err("bad_schematic", format!("{}: {e}", n(&sch)), "Fix or remove the broken schematic first."))?;+        let is_root = sch.file_stem().map(|s| s.to_string_lossy() == old).unwrap_or(false);+        if is_root {+            plan.writes.push((target("kicad_sch"), out));+            plan.removes.push(sch.clone());+            plan.changes.push(format!("root schematic renamed, {count} instance header(s) updated"));+        } else if count > 0 {+            plan.writes.push((sch.clone(), out));+            plan.changes.push(format!("{}: {count} instance header(s) updated", sch.file_name().unwrap().to_string_lossy()));+        }+    }+    let pcb = dir.join(format!("{old}.kicad_pcb"));+    if pcb.is_file() {+        let t = std::fs::read_to_string(&pcb).map_err(|e| err("read_failed", e.to_string(), "Check permissions."))?;+        let (out, count) = rename_in_board(&t, &old, new).map_err(|e| err("bad_board", e, "Fix the board first (kicad_lint_board)."))?;+        plan.writes.push((target("kicad_pcb"), out));+        plan.removes.push(pcb);+        plan.changes.push(format!("board renamed, {count} sheet reference(s) updated"));+    }+    let dru = dir.join(format!("{old}.kicad_dru"));+    if dru.is_file() {+        plan.moves.push((dru, target("kicad_dru")));+    }+    let backups = dir.join(format!("{old}-backups"));+    if backups.is_dir() && !dir.join(format!("{new}-backups")).exists() {+        plan.moves.push((backups, dir.join(format!("{new}-backups"))));+    }+    // Every written file must parse again.+    for (p, text) in &plan.writes {+        let ext = p.extension().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();+        let ok = match ext.as_str() {+            "kicad_pro" | "kicad_prl" => serde_json::from_str::<Value>(text).is_ok() || ext == "kicad_prl",+            "kicad_sch" | "kicad_pcb" => Document::parse(text).is_ok(),+            _ => true,+        };+        if !ok {+            return Err(err("internal", format!("rewritten {} does not parse", n(p)), "File a bug; nothing was changed."));+        }+    }+    Ok(plan)+}++/// Write the new files, then move and remove the old ones.+pub fn apply(plan: &RenamePlan) -> Result<(), String> {+    for (p, text) in &plan.writes {+        write_atomic(p, text.as_bytes())?;+    }+    for (a, b) in &plan.moves {+        std::fs::rename(a, b).map_err(|e| format!("cannot move {} to {}: {e}", n(a), n(b)))?;+    }+    let written: Vec<&PathBuf> = plan.writes.iter().map(|(p, _)| p).collect();+    for p in &plan.removes {+        if !written.contains(&p) {+            std::fs::remove_file(p).map_err(|e| format!("cannot remove {}: {e}", n(p)))?;+        }+    }+    Ok(())+}++// ---------------------------------------------------------------------------+// Snapshot+// ---------------------------------------------------------------------------++/// The files a snapshot copies: the project's KiCad documents and local tables (top level+/// of the project folder only; libraries in sub-folders are referenced, not copied).+pub fn snapshot_files(dir: &Path) -> Vec<PathBuf> {+    let mut out: Vec<PathBuf> = std::fs::read_dir(dir)+        .map(|rd| {+            rd.flatten()+                .map(|e| e.path())+                .filter(|p| p.is_file())+                .filter(|p| {+                    let f = p.file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();+                    !f.starts_with('~') && (["kicad_pro", "kicad_sch", "kicad_pcb", "kicad_prl", "kicad_dru", "kicad_sym", "kicad_jobset"].iter().any(|x| f.ends_with(&format!(".{x}"))) || f == "sym-lib-table" || f == "fp-lib-table")+                })+                .collect()+        })+        .unwrap_or_default();+    out.sort();+    out+}++/// A filesystem-safe label.+pub fn safe_label(s: &str) -> String {+    s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect::<String>().trim_matches('-').chars().take(40).collect()+}++/// Copy the project files into `<dir>/snapshots/<stamp>[-label]/`. Returns (folder, copied).+pub fn snapshot(pro: &Path, stamp: &str, label: Option<&str>) -> Result<(PathBuf, Vec<String>), String> {+    let dir = pro.parent().unwrap_or(Path::new("."));+    let name = match label.map(safe_label).filter(|l| !l.is_empty()) {+        Some(l) => format!("{stamp}-{l}"),+        None => stamp.to_string(),+    };+    let mut dest = dir.join("snapshots").join(&name);+    let mut k = 2;+    while dest.exists() {+        dest = dir.join("snapshots").join(format!("{name}-{k}"));+        k += 1;+    }+    std::fs::create_dir_all(&dest).map_err(|e| format!("cannot create {}: {e}", n(&dest)))?;+    let mut copied = Vec::new();+    for f in snapshot_files(dir) {+        let to = dest.join(f.file_name().unwrap());+        std::fs::copy(&f, &to).map_err(|e| format!("cannot copy {}: {e}", n(&f)))?;+        copied.push(f.file_name().unwrap().to_string_lossy().to_string());+    }+    Ok((dest, copied))+}++#[cfg(test)]+mod tests {+    use super::*;++    fn tmp(tag: &str) -> PathBuf {+        let d = std::env::temp_dir().join(format!("kc-project-{tag}-{}-{}", std::process::id(), new_uuid()));+        std::fs::create_dir_all(&d).unwrap();+        d+    }++    #[test]+    fn create_writes_three_files_that_parse_again() {+        let d = tmp("create");+        let o = CreateOpts { title: Some("Widget \"A\""), layers: 4, fab_rules: Some("jlcpcb"), text_vars: BTreeMap::new(), dry_run: false };+        let r = create(&d, "widget", &o).unwrap();+        assert_eq!(r["files"].as_array().unwrap().len(), 3);+        let pdir = d.join("widget");+        let pro: Value = serde_json::from_str(&std::fs::read_to_string(pdir.join("widget.kicad_pro")).unwrap()).unwrap();+        assert_eq!(pro["meta"]["filename"], "widget.kicad_pro");+        assert_eq!(pro["text_variables"]["FAB_RULES"], "jlcpcb");+        assert_eq!(pro["schematic"]["top_level_sheets"][0]["uuid"], r["rootSheetUuid"]);+        let sch = std::fs::read_to_string(pdir.join("widget.kicad_sch")).unwrap();+        assert!(sch.contains("(version 20260306)") && sch.contains("(title \"Widget \\\"A\\\"\")"));+        Document::parse(&sch).unwrap();+        let b = crate::pcb::parse_pcb(&pdir.join("widget.kicad_pcb")).unwrap();+        assert_eq!(crate::pcb::copper_layers(&b), vec!["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]);+        assert_eq!(b.title_block["title"], "Widget \"A\"");+        let again = create(&d, "widget", &o).unwrap_err();+        assert_eq!(again["errorCode"], "project_exists");+        assert_eq!(create(&d, "a/b", &o).unwrap_err()["errorCode"], "bad_arg");+        let dry = create(&d, "dry", &CreateOpts { dry_run: true, layers: 2, ..o }).unwrap();+        assert_eq!(dry["dryRun"], true);+        assert!(!d.join("dry").exists());+        assert!(blank_board(None, 3).is_err());+        let _ = std::fs::remove_dir_all(&d);+    }++    fn sample_project(d: &Path) -> PathBuf {+        let o = CreateOpts { title: Some("old"), layers: 2, fab_rules: None, text_vars: [("REV".to_string(), "A".to_string())].into_iter().collect(), dry_run: false };+        create(d, "old", &o).unwrap();+        let p = d.join("old");+        // a child sheet with an instance header, and the root referencing it+        let child = "(kicad_sch\n\t(version 20260306)\n\t(generator \"eeschema\")\n\t(uuid \"c1\")\n\t(paper \"A4\")\n\t(lib_symbols)\n\t(symbol\n\t\t(lib_id \"Device:R\")\n\t\t(at 10 10 0)\n\t\t(instances\n\t\t\t(project \"old\"\n\t\t\t\t(path \"/r/s\"\n\t\t\t\t\t(reference \"R1\")\n\t\t\t\t\t(unit 1)\n\t\t\t\t)\n\t\t\t)\n\t\t)\n\t)\n)\n";+        std::fs::write(p.join("power.kicad_sch"), child).unwrap();+        let root = std::fs::read_to_string(p.join("old.kicad_sch")).unwrap();+        let root = root.replace("\t(lib_symbols)\n", "\t(lib_symbols)\n\t(sheet\n\t\t(at 20 20)\n\t\t(size 10 10)\n\t\t(property \"Sheetname\" \"Power\"\n\t\t\t(at 20 19 0)\n\t\t)\n\t\t(property \"Sheetfile\" \"power.kicad_sch\"\n\t\t\t(at 20 31 0)\n\t\t)\n\t\t(instances\n\t\t\t(project \"old\"\n\t\t\t\t(path \"/r\"\n\t\t\t\t\t(page \"2\")\n\t\t\t\t)\n\t\t\t)\n\t\t)\n\t)\n");+        std::fs::write(p.join("old.kicad_sch"), root).unwrap();+        let pcb = std::fs::read_to_string(p.join("old.kicad_pcb")).unwrap();+        let pcb = pcb.replace("\t(embedded_fonts no)\n)", "\t(footprint \"R:R\"\n\t\t(layer \"F.Cu\")\n\t\t(at 1 1)\n\t\t(property \"Reference\" \"R1\")\n\t\t(sheetname \"/\")\n\t\t(sheetfile \"old.kicad_sch\")\n\t)\n\t(embedded_fonts no)\n)");+        std::fs::write(p.join("old.kicad_pcb"), pcb).unwrap();+        std::fs::write(p.join("old.kicad_prl"), "{\n  \"meta\": {\n    \"filename\": \"old.kicad_prl\",\n    \"version\": 5\n  }\n}\n").unwrap();+        std::fs::write(p.join("old.kicad_dru"), "(version 1)\n").unwrap();+        p.join("old.kicad_pro")+    }++    #[test]+    fn info_reports_files_sheets_board_and_variables() {+        let d = tmp("info");+        let pro = sample_project(&d);+        let i = info(&pro).unwrap();+        assert_eq!(i["name"], "old");+        let sheets: Vec<&str> = i["sheets"].as_array().unwrap().iter().map(|s| s["file"].as_str().unwrap()).collect();+        assert_eq!(sheets, vec!["old.kicad_sch", "power.kicad_sch"]);+        assert_eq!(i["sheets"][1]["name"], "Power");+        assert_eq!(i["board"]["footprints"], 1);+        assert_eq!(i["textVariables"]["REV"], "A");+        assert_eq!(i["netClasses"][0]["name"], "Default");+        assert!(i["lastModified"].as_str().unwrap().ends_with('Z'));+        assert_eq!(resolve_pro(&pro.with_extension("kicad_pcb")).unwrap(), pro);+        assert_eq!(resolve_pro(pro.parent().unwrap()).unwrap(), pro);+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn rename_moves_files_and_rewrites_references() {+        let d = tmp("rename");+        let pro = sample_project(&d);+        let p = pro.parent().unwrap().to_path_buf();+        let plan = rename_plan(&pro, "new").unwrap();+        assert!(plan.public()["changes"].as_array().unwrap().len() >= 3);+        apply(&plan).unwrap();+        for f in ["new.kicad_pro", "new.kicad_sch", "new.kicad_pcb", "new.kicad_prl", "new.kicad_dru", "power.kicad_sch"] {+            assert!(p.join(f).is_file(), "{f}");+        }+        for f in ["old.kicad_pro", "old.kicad_sch", "old.kicad_pcb", "old.kicad_prl", "old.kicad_dru"] {+            assert!(!p.join(f).exists(), "{f} should be gone");+        }+        let pro2: Value = serde_json::from_str(&std::fs::read_to_string(p.join("new.kicad_pro")).unwrap()).unwrap();+        assert_eq!(pro2["meta"]["filename"], "new.kicad_pro");+        assert_eq!(pro2["schematic"]["top_level_sheets"][0]["filename"], "new.kicad_sch");+        assert_eq!(pro2["schematic"]["top_level_sheets"][0]["name"], "new");+        let child = std::fs::read_to_string(p.join("power.kicad_sch")).unwrap();+        assert!(child.contains("(project \"new\"") && !child.contains("\"old\""));+        let root = std::fs::read_to_string(p.join("new.kicad_sch")).unwrap();+        assert!(root.contains("(project \"new\"") && root.contains("(property \"Sheetfile\" \"power.kicad_sch\""));+        let pcb = std::fs::read_to_string(p.join("new.kicad_pcb")).unwrap();+        assert!(pcb.contains("(sheetfile \"new.kicad_sch\")") && pcb.contains("(title \"new\")"));+        let prl: Value = serde_json::from_str(&std::fs::read_to_string(p.join("new.kicad_prl")).unwrap()).unwrap();+        assert_eq!(prl["meta"]["filename"], "new.kicad_prl");+        assert_eq!(rename_plan(&p.join("new.kicad_pro"), "new").unwrap_err()["errorCode"], "bad_arg");+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn rename_refuses_an_existing_target_and_locks_are_seen() {+        let d = tmp("rename-refuse");+        let pro = sample_project(&d);+        let p = pro.parent().unwrap();+        std::fs::write(p.join("taken.kicad_pcb"), "x").unwrap();+        assert_eq!(rename_plan(&pro, "taken").unwrap_err()["errorCode"], "target_exists");+        assert!(open_locks(p).is_empty());+        std::fs::write(p.join("~old.kicad_sch.lck"), "{}").unwrap();+        assert_eq!(open_locks(p), vec!["~old.kicad_sch.lck"]);+        let _ = std::fs::remove_dir_all(&d);+    }++    #[test]+    fn snapshot_copies_the_project_files() {+        let d = tmp("snap");+        let pro = sample_project(&d);+        let (dest, copied) = snapshot(&pro, "20261027-120000", Some("before route!")).unwrap();+        assert!(dest.ends_with("snapshots/20261027-120000-before-route"));+        assert!(copied.contains(&"old.kicad_pcb".to_string()) && copied.contains(&"power.kicad_sch".to_string()) && copied.contains(&"old.kicad_dru".to_string()));+        let (dest2, _) = snapshot(&pro, "20261027-120000", Some("before route!")).unwrap();+        assert_ne!(dest, dest2, "a second snapshot in the same second gets its own folder");+        let _ = std::fs::remove_dir_all(&d);+    }+}
rust/crates/kicad-core/src/project_rules.rsadded+912
@@ -0,0 +1,912 @@+//! Project-level rules on disk: the netclasses and board constraints KiCad keeps in the+//! `.kicad_pro` (JSON), and the custom rules in the `.kicad_dru` (s-expressions). Pure+//! reading and editing plus one atomic writer; the verbs in `kicad-bridge` decide when a+//! write is allowed (KiCad holds both files in memory while the project is open and+//! writes its own copy back on save, so an edit under an open KiCad is lost).+//!+//! Units are millimetres throughout, as KiCad stores them in both files.++use std::path::{Path, PathBuf};++use serde_json::{json, Map, Value};++use crate::pcb::RoutingError;++type R<T> = Result<T, RoutingError>;++// ---------------------------------------------------------------------------+// Locating and writing the files+// ---------------------------------------------------------------------------++/// The `.kicad_pro` a call addresses: `projectPath`, or the project beside `filePath`+/// (a board, schematic or rules file with the same stem).+pub fn project_path(args: &Value) -> R<PathBuf> {+    let raw = ["projectPath", "filePath", "boardPath"].iter().find_map(|k| args.get(*k).and_then(Value::as_str)).map(str::trim).filter(|s| !s.is_empty());+    let Some(raw) = raw else {+        return Err(RoutingError::new("missing_arg", "Pass projectPath (the .kicad_pro) or filePath (the board beside it)"));+    };+    let p = PathBuf::from(raw);+    let pro = if p.extension().map(|e| e == "kicad_pro").unwrap_or(false) { p } else { p.with_extension("kicad_pro") };+    if !pro.is_file() {+        return Err(RoutingError::new("project_not_found", format!("no project file at {}", pro.to_string_lossy().replace('\\', "/")))+            .with("_hint", json!("Netclasses and board constraints live in the .kicad_pro beside the board (same stem). Open the board in KiCad once and save to create it.")));+    }+    Ok(pro)+}++/// KiCad's lock files for the project, its board and its schematic: present while a KiCad+/// editor has them open.+pub fn open_locks(pro: &Path) -> Vec<String> {+    let mut out = Vec::new();+    for ext in ["kicad_pro", "kicad_pcb", "kicad_sch"] {+        let f = pro.with_extension(ext);+        if let Some(name) = f.file_name() {+            let lck = f.with_file_name(format!("~{}.lck", name.to_string_lossy()));+            if lck.exists() {+                out.push(lck.to_string_lossy().replace('\\', "/"));+            }+        }+    }+    out+}++pub fn read_json(path: &Path) -> R<Value> {+    let text = std::fs::read_to_string(path).map_err(|e| RoutingError::new("project_unreadable", format!("cannot read {}: {e}", path.display())))?;+    serde_json::from_str(&text).map_err(|e| RoutingError::new("project_unparsable", format!("{} is not valid JSON: {e}", path.display())))+}++/// KiCad's own layout: two-space indent, keys sorted (serde_json's map is sorted), newline at the end.+pub fn project_text(v: &Value) -> String {+    let mut s = serde_json::to_string_pretty(v).unwrap_or_default();+    s.push('\n');+    s+}++/// Replace `path` atomically (unique temp file, fsync, rename) after copying the old file+/// to `<path>.adom-bak`. Returns the backup path. Refuses when the file changed since it+/// was read (`expected`).+pub fn write_atomic(path: &Path, text: &str, expected: Option<&str>) -> R<String> {+    if let Some(exp) = expected {+        let now = std::fs::read_to_string(path).unwrap_or_default();+        if now != exp {+            return Err(RoutingError::new("file_changed", format!("{} changed while the edit was planned; read it again", path.display())).with("mutated", json!(false)));+        }+    }+    let bak = format!("{}.adom-bak", path.to_string_lossy());+    if path.exists() {+        std::fs::copy(path, &bak).map_err(|e| RoutingError::new("write_failed", format!("cannot back up {}: {e}", path.display())).with("mutated", json!(false)))?;+    }+    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 nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);+    let tmp = dir.join(format!("{name}.{}.{nonce}.adom-tmp", std::process::id()));+    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(text.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| RoutingError::new("write_failed", format!("cannot replace {}: {e}", path.display())).with("mutated", json!(false)))?;+    Ok(bak.replace('\\', "/"))+}++pub const RELOAD_NOTE: &str = "KiCad reads the project file when the project or board is opened and writes its own copy back when you save, so close KiCad (or the project) before this edit and reopen it afterwards; an editor that was open during the edit will overwrite it on its next save.";++// ---------------------------------------------------------------------------+// Netclasses+// ---------------------------------------------------------------------------++/// The netclass fields a caller may set: (camelCase argument, KiCad key).+pub const CLASS_FIELDS: &[(&str, &str)] = &[+    ("clearance", "clearance"),+    ("trackWidth", "track_width"),+    ("viaDiameter", "via_diameter"),+    ("viaDrill", "via_drill"),+    ("microviaDiameter", "microvia_diameter"),+    ("microviaDrill", "microvia_drill"),+    ("diffPairWidth", "diff_pair_width"),+    ("diffPairGap", "diff_pair_gap"),+    ("diffPairViaGap", "diff_pair_via_gap"),+    ("wireWidth", "wire_width"),+    ("busWidth", "bus_width"),+    ("priority", "priority"),+];++/// KiCad's Default netclass values (netclass.cpp defaults, KiCad 8 to 10).+pub fn default_class() -> Value {+    json!({+        "bus_width": 12, "clearance": 0.2, "diff_pair_gap": 0.25, "diff_pair_via_gap": 0.25, "diff_pair_width": 0.2,+        "line_style": 0, "microvia_diameter": 0.3, "microvia_drill": 0.1, "name": "Default", "pcb_color": "rgba(0, 0, 0, 0.000)",+        "priority": 2147483647, "schematic_color": "rgba(0, 0, 0, 0.000)", "track_width": 0.2, "via_diameter": 0.6, "via_drill": 0.3, "wire_width": 6+    })+}++/// KiCad netclass pattern match: `*` any run, `?` one character, anchored at both ends.+/// KiCad also accepts a regular expression; a pattern with regex syntax is reported as+/// such (`regex: true`) and not evaluated here.+pub fn wildcard(pattern: &str, name: &str) -> bool {+    let p: Vec<char> = pattern.chars().collect();+    let s: Vec<char> = name.chars().collect();+    let (mut pi, mut si, mut star, mut mark) = (0usize, 0usize, None::<usize>, 0usize);+    while si < s.len() {+        if pi < p.len() && (p[pi] == '?' || p[pi] == s[si]) {+            pi += 1;+            si += 1;+        } else if pi < p.len() && p[pi] == '*' {+            star = Some(pi);+            mark = si;+            pi += 1;+        } else if let Some(st) = star {+            pi = st + 1;+            mark += 1;+            si = mark;+        } else {+            return false;+        }+    }+    while pi < p.len() && p[pi] == '*' {+        pi += 1;+    }+    pi == p.len()+}++pub fn looks_regex(pattern: &str) -> bool {+    pattern.chars().any(|c| matches!(c, '[' | ']' | '(' | ')' | '|' | '^' | '$' | '\\' | '{' | '}')) || pattern.contains(".*")+}++fn classes(pro: &Value) -> Vec<Value> {+    pro.pointer("/net_settings/classes").and_then(Value::as_array).cloned().unwrap_or_default()+}++fn patterns(pro: &Value) -> Vec<(String, String)> {+    pro.pointer("/net_settings/netclass_patterns")+        .and_then(Value::as_array)+        .map(|a| a.iter().filter_map(|p| Some((p.get("netclass")?.as_str()?.to_string(), p.get("pattern")?.as_str()?.to_string()))).collect())+        .unwrap_or_default()+}++/// Explicit `netclass_assignments` (net -> class or classes), when the project has any.+fn assignments(pro: &Value) -> Vec<(String, String)> {+    let mut out = Vec::new();+    if let Some(m) = pro.pointer("/net_settings/netclass_assignments").and_then(Value::as_object) {+        for (net, c) in m {+            match c {+                Value::String(s) => out.push((net.clone(), s.clone())),+                Value::Array(a) => out.extend(a.iter().filter_map(Value::as_str).map(|s| (net.clone(), s.to_string()))),+                _ => {}+            }+        }+    }+    out+}++/// The class a net resolves to: an explicit assignment first, then the matching pattern+/// whose class has the best (lowest) priority, else Default.+pub fn class_for_net(pro: &Value, net: &str) -> Value {+    let all = classes(pro);+    let find = |name: &str| all.iter().find(|c| c.get("name").and_then(Value::as_str) == Some(name)).cloned();+    if let Some((_, c)) = assignments(pro).into_iter().find(|(n, _)| n == net) {+        if let Some(v) = find(&c) {+            return v;+        }+    }+    let mut hits: Vec<Value> = patterns(pro).into_iter().filter(|(_, p)| !looks_regex(p) && wildcard(p, net)).filter_map(|(c, _)| find(&c)).collect();+    hits.sort_by_key(|c| c.get("priority").and_then(Value::as_i64).unwrap_or(i64::MAX));+    hits.into_iter().next().or_else(|| find("Default")).unwrap_or_else(default_class)+}++fn camel_class(c: &Value) -> Value {+    let mut out = json!({"name": c.get("name").cloned().unwrap_or(Value::Null)});+    for (camel, key) in CLASS_FIELDS {+        if let Some(v) = c.get(*key) {+            out[*camel] = v.clone();+        }+    }+    out+}++/// `kicad_netclasses` read: every class with its fields, its patterns and the board nets+/// those patterns match; nets no pattern matches fall to Default.+pub fn netclasses_report(pro: &Value, board_nets: &[String]) -> Value {+    let pats = patterns(pro);+    let assigns = assignments(pro);+    let mut rows = Vec::new();+    for c in classes(pro) {+        let name = c.get("name").and_then(Value::as_str).unwrap_or("").to_string();+        let mine: Vec<&(String, String)> = pats.iter().filter(|(cl, _)| *cl == name).collect();+        let mut nets: Vec<String> = board_nets.iter().filter(|n| class_for_net(pro, n).get("name").and_then(Value::as_str) == Some(name.as_str())).cloned().collect();+        nets.sort();+        let mut row = camel_class(&c);+        row["patterns"] = json!(mine.iter().map(|(_, p)| json!({"pattern": p, "regex": looks_regex(p), "matches": if looks_regex(p) { Value::Null } else { json!(board_nets.iter().filter(|n| wildcard(p, n)).collect::<Vec<_>>()) }})).collect::<Vec<_>>());+        row["explicitNets"] = json!(assigns.iter().filter(|(_, cl)| *cl == name).map(|(n, _)| n).collect::<Vec<_>>());+        row["nets"] = json!(nets);+        row["netCount"] = json!(nets.len());+        rows.push(row);+    }+    json!({"classes": rows, "classCount": rows.len(), "patternCount": pats.len(), "boardNetCount": board_nets.len(), "raw": pro.get("net_settings").cloned().unwrap_or(Value::Null)})+}++fn num(v: &Value, label: &str) -> R<f64> {+    let f = v.as_f64().ok_or_else(|| RoutingError::new("invalid_value", format!("{label} must be a number in mm")))?;+    if !f.is_finite() || f < 0.0 {+        return Err(RoutingError::new("invalid_value", format!("{label} must be a finite, non-negative number in mm")));+    }+    Ok(f)+}++fn ensure_net_settings(pro: &mut Value) -> &mut Map<String, Value> {+    if !pro.get("net_settings").map(Value::is_object).unwrap_or(false) {+        pro["net_settings"] = json!({"classes": [], "meta": {"version": 5}, "netclass_patterns": []});+    }+    let ns = pro["net_settings"].as_object_mut().unwrap();+    if !ns.get("classes").map(Value::is_array).unwrap_or(false) {+        ns.insert("classes".into(), json!([]));+    }+    if !ns.get("netclass_patterns").map(Value::is_array).unwrap_or(false) {+        ns.insert("netclass_patterns".into(), json!([]));+    }+    ns+}++/// Apply a `kicad_netclasses` edit to the parsed project. `set: {name, clearance?, ...}`+/// creates or updates a class (a new class starts from the Default class's values),+/// `assign: {netclass, patterns: [...]}` adds patterns (an exact net name is a valid+/// pattern), `unassign: {patterns: [...]}` removes them, `remove: "Name"` drops a class+/// and its patterns. Returns the list of changes; an empty list means nothing changed.+pub fn edit_netclasses(pro: &mut Value, args: &Value) -> R<Vec<Value>> {+    let mut changes = Vec::new();+    if let Some(set) = args.get("set") {+        let sets: Vec<Value> = match set {+            Value::Array(a) => a.clone(),+            Value::Object(_) => vec![set.clone()],+            _ => return Err(RoutingError::new("invalid_set", "set is {name, clearance?, trackWidth?, viaDiameter?, viaDrill?, diffPairWidth?, diffPairGap?, ...} or a list of them")),+        };+        for s in sets {+            let name = s.get("name").and_then(Value::as_str).map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| RoutingError::new("invalid_set", "every class in set needs a name"))?.to_string();+            let mut fields: Vec<(&str, Value)> = Vec::new();+            for (camel, key) in CLASS_FIELDS {+                let v = s.get(*camel).or_else(|| s.get(*key));+                if let Some(v) = v.filter(|v| !v.is_null()) {+                    let f = num(v, camel)?;+                    fields.push((key, if *key == "priority" || *key == "wire_width" || *key == "bus_width" { json!(f as i64) } else { json!(f) }));+                }+            }+            if let (Some(d), Some(w)) = (fields.iter().find(|f| f.0 == "via_drill").and_then(|f| f.1.as_f64()), fields.iter().find(|f| f.0 == "via_diameter").and_then(|f| f.1.as_f64())) {+                if d >= w {+                    return Err(RoutingError::new("invalid_value", format!("class {name}: viaDrill {d} must be smaller than viaDiameter {w}")));+                }+            }+            let base = classes(pro).into_iter().find(|c| c.get("name").and_then(Value::as_str) == Some("Default")).unwrap_or_else(default_class);+            let ns = ensure_net_settings(pro);+            let list = ns.get_mut("classes").and_then(Value::as_array_mut).unwrap();+            match list.iter_mut().find(|c| c.get("name").and_then(Value::as_str) == Some(name.as_str())) {+                Some(c) => {+                    for (k, v) in &fields {+                        if c.get(*k) != Some(v) {+                            changes.push(json!({"class": name, "field": k, "from": c.get(*k).cloned().unwrap_or(Value::Null), "to": v}));+                            c[*k] = v.clone();+                        }+                    }+                }+                None => {+                    let mut c = base.clone();+                    c["name"] = json!(name);+                    if name != "Default" {+                        c["priority"] = json!(0);+                    }+                    for (k, v) in &fields {+                        c[*k] = v.clone();+                    }+                    changes.push(json!({"class": name, "created": true, "values": camel_class(&c)}));+                    list.push(c);+                }+            }+        }+    }+    if let Some(a) = args.get("assign") {+        let class = a.get("netclass").or_else(|| a.get("class")).and_then(Value::as_str).ok_or_else(|| RoutingError::new("invalid_assign", "assign is {netclass, patterns:[\"USB_*\", \"GND\"]}"))?.to_string();+        let pats: Vec<String> = a.get("patterns").or_else(|| a.get("nets")).and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();+        if pats.is_empty() {+            return Err(RoutingError::new("invalid_assign", "assign.patterns lists at least one pattern or net name"));+        }+        if !classes(pro).iter().any(|c| c.get("name").and_then(Value::as_str) == Some(class.as_str())) {+            return Err(RoutingError::new("unknown_netclass", format!("no netclass named {class}; create it with set first (both may go in one call)")));+        }+        let ns = ensure_net_settings(pro);+        let list = ns.get_mut("netclass_patterns").and_then(Value::as_array_mut).unwrap();+        for p in pats {+            let exists = list.iter().any(|e| e.get("pattern").and_then(Value::as_str) == Some(p.as_str()) && e.get("netclass").and_then(Value::as_str) == Some(class.as_str()));+            if !exists {+                list.push(json!({"netclass": class, "pattern": p}));+                changes.push(json!({"assigned": p, "netclass": class}));+            }+        }+    }+    if let Some(u) = args.get("unassign") {+        let pats: Vec<String> = u.get("patterns").and_then(Value::as_array).map(|v| v.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();+        let class = u.get("netclass").and_then(Value::as_str).map(str::to_string);+        let ns = ensure_net_settings(pro);+        let list = ns.get_mut("netclass_patterns").and_then(Value::as_array_mut).unwrap();+        list.retain(|e| {+            let p = e.get("pattern").and_then(Value::as_str).unwrap_or("");+            let c = e.get("netclass").and_then(Value::as_str).unwrap_or("");+            let hit = pats.iter().any(|x| x == p) && class.as_deref().map(|k| k == c).unwrap_or(true);+            if hit {+                changes.push(json!({"unassigned": p, "netclass": c}));+            }+            !hit+        });+    }+    if let Some(r) = args.get("remove").and_then(Value::as_str) {+        if r == "Default" {+            return Err(RoutingError::new("invalid_remove", "the Default netclass cannot be removed"));+        }+        let ns = ensure_net_settings(pro);+        let list = ns.get_mut("classes").and_then(Value::as_array_mut).unwrap();+        let before = list.len();+        list.retain(|c| c.get("name").and_then(Value::as_str) != Some(r));+        if list.len() == before {+            return Err(RoutingError::new("unknown_netclass", format!("no netclass named {r}")));+        }+        changes.push(json!({"removedClass": r}));+        if let Some(p) = ns.get_mut("netclass_patterns").and_then(Value::as_array_mut) {+            p.retain(|e| e.get("netclass").and_then(Value::as_str) != Some(r));+        }+    }+    Ok(changes)+}++// ---------------------------------------------------------------------------+// Board constraints and predefined sizes+// ---------------------------------------------------------------------------++/// The board constraints (Board Setup > Design Rules > Constraints): camelCase argument+/// and KiCad's key under `board.design_settings.rules`.+pub const RULE_FIELDS: &[(&str, &str)] = &[+    ("minClearance", "min_clearance"),+    ("minTrackWidth", "min_track_width"),+    ("minConnection", "min_connection"),+    ("minViaDiameter", "min_via_diameter"),+    ("minViaAnnularWidth", "min_via_annular_width"),+    ("minThroughHoleDiameter", "min_through_hole_diameter"),+    ("minHoleClearance", "min_hole_clearance"),+    ("minHoleToHole", "min_hole_to_hole"),+    ("minCopperEdgeClearance", "min_copper_edge_clearance"),+    ("minMicroviaDiameter", "min_microvia_diameter"),+    ("minMicroviaDrill", "min_microvia_drill"),+    ("minSilkClearance", "min_silk_clearance"),+    ("minTextHeight", "min_text_height"),+    ("minTextThickness", "min_text_thickness"),+    ("minGrooveWidth", "min_groove_width"),+    ("solderMaskToCopperClearance", "solder_mask_to_copper_clearance"),+];++/// KiCad stores each predefined-size list with a leading zero row (the "use netclass"+/// entry of the Board Setup table); the reply hides it and a write puts it back.+fn strip_lead<T: Clone>(v: &[T], is_zero: impl Fn(&T) -> bool) -> Vec<T> {+    match v.first() {+        Some(f) if is_zero(f) => v[1..].to_vec(),+        _ => v.to_vec(),+    }+}++pub fn board_rules_report(pro: &Value) -> Value {+    let ds = pro.pointer("/board/design_settings").cloned().unwrap_or(Value::Null);+    let rules = ds.get("rules").cloned().unwrap_or(json!({}));+    let mut constraints = json!({});+    for (camel, key) in RULE_FIELDS {+        constraints[*camel] = rules.get(*key).cloned().unwrap_or(Value::Null);+    }+    let zero = |v: &Value| v.as_f64() == Some(0.0);+    let tw: Vec<Value> = strip_lead(ds.get("track_widths").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]), zero);+    let vz = |v: &Value| v.get("diameter").and_then(Value::as_f64) == Some(0.0);+    let vias: Vec<Value> = strip_lead(ds.get("via_dimensions").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]), vz);+    let dz = |v: &Value| v.get("width").and_then(Value::as_f64) == Some(0.0);+    let dps: Vec<Value> = strip_lead(ds.get("diff_pair_dimensions").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[]), dz);+    json!({+        "constraints": constraints,+        "trackWidths": tw,+        "viaSizes": vias.iter().map(|v| json!({"diameter": v.get("diameter"), "drill": v.get("drill")})).collect::<Vec<_>>(),+        "diffPairs": dps.iter().map(|v| json!({"width": v.get("width"), "gap": v.get("gap"), "viaGap": v.get("via_gap")})).collect::<Vec<_>>(),+        "rawRules": rules,+    })+}++/// Apply `rules: {minClearance: 0.15, ...}` (camelCase or KiCad's snake keys),+/// `trackWidths: [0.2, 0.3]`, `viaSizes: [{diameter, drill}]`, `diffPairs: [{width, gap, viaGap?}]`.+pub fn edit_board_rules(pro: &mut Value, args: &Value) -> R<Vec<Value>> {+    let mut changes = Vec::new();+    if !pro.pointer("/board/design_settings").map(Value::is_object).unwrap_or(false) {+        return Err(RoutingError::new("project_without_design_settings", "the project file has no board.design_settings; open the board in KiCad once and save so KiCad writes them"));+    }+    if let Some(r) = args.get("rules") {+        let obj = r.as_object().ok_or_else(|| RoutingError::new("invalid_rules", "rules is an object like {\"minClearance\": 0.15}"))?;+        let ds = pro.pointer_mut("/board/design_settings").unwrap();+        if !ds.get("rules").map(Value::is_object).unwrap_or(false) {+            ds["rules"] = json!({});+        }+        for (k, v) in obj {+            let key = RULE_FIELDS.iter().find(|(c, s)| c == k || s == k).map(|(_, s)| *s).ok_or_else(|| {+                RoutingError::new("unknown_rule", format!("unknown board constraint {k}")).with("known", json!(RULE_FIELDS.iter().map(|(c, _)| c).collect::<Vec<_>>()))+            })?;+            let f = num(v, k)?;+            if ds["rules"].get(key).and_then(Value::as_f64) != Some(f) {+                changes.push(json!({"constraint": key, "from": ds["rules"].get(key).cloned().unwrap_or(Value::Null), "to": f}));+                ds["rules"][key] = json!(f);+            }+        }+    }+    let ds = pro.pointer_mut("/board/design_settings").unwrap();+    if let Some(tw) = args.get("trackWidths") {+        let list = tw.as_array().ok_or_else(|| RoutingError::new("invalid_track_widths", "trackWidths is a list of widths in mm"))?;+        let mut vals = vec![json!(0.0)];+        for v in list {+            let f = num(v, "trackWidths[]")?;+            if f <= 0.0 {+                return Err(RoutingError::new("invalid_value", "every track width must be positive"));+            }+            vals.push(json!(f));+        }+        let new = Value::Array(vals);+        if ds.get("track_widths") != Some(&new) {+            changes.push(json!({"list": "track_widths", "to": tw}));+            ds["track_widths"] = new;+        }+    }+    if let Some(vs) = args.get("viaSizes") {+        let list = vs.as_array().ok_or_else(|| RoutingError::new("invalid_via_sizes", "viaSizes is a list of {diameter, drill} in mm"))?;+        let mut vals = vec![json!({"diameter": 0.0, "drill": 0.0})];+        for v in list {+            let d = num(v.get("diameter").unwrap_or(&Value::Null), "viaSizes[].diameter")?;+            let h = num(v.get("drill").unwrap_or(&Value::Null), "viaSizes[].drill")?;+            if h <= 0.0 || h >= d {+                return Err(RoutingError::new("invalid_value", format!("via {d}/{h}: drill must be positive and smaller than the diameter")));+            }+            vals.push(json!({"diameter": d, "drill": h}));+        }+        let new = Value::Array(vals);+        if ds.get("via_dimensions") != Some(&new) {+            changes.push(json!({"list": "via_dimensions", "to": vs}));+            ds["via_dimensions"] = new;+        }+    }+    if let Some(dp) = args.get("diffPairs") {+        let list = dp.as_array().ok_or_else(|| RoutingError::new("invalid_diff_pairs", "diffPairs is a list of {width, gap, viaGap?} in mm"))?;+        let mut vals = vec![json!({"gap": 0.0, "via_gap": 0.0, "width": 0.0})];+        for v in list {+            let w = num(v.get("width").unwrap_or(&Value::Null), "diffPairs[].width")?;+            let g = num(v.get("gap").unwrap_or(&Value::Null), "diffPairs[].gap")?;+            let vg = match v.get("viaGap") {+                Some(x) => num(x, "diffPairs[].viaGap")?,+                None => g,+            };+            vals.push(json!({"gap": g, "via_gap": vg, "width": w}));+        }+        let new = Value::Array(vals);+        if ds.get("diff_pair_dimensions") != Some(&new) {+            changes.push(json!({"list": "diff_pair_dimensions", "to": dp}));+            ds["diff_pair_dimensions"] = new;+        }+    }+    Ok(changes)+}++// ---------------------------------------------------------------------------+// Custom rules (.kicad_dru)+// ---------------------------------------------------------------------------++/// Constraint types KiCad 8 to 10 accept in a `(constraint TYPE ...)`.+pub const CONSTRAINT_TYPES: &[&str] = &[+    "annular_width", "assertion", "bridged_mask", "clearance", "connection_width", "courtyard_clearance", "creepage", "diff_pair_gap", "diff_pair_uncoupled",+    "disallow", "edge_clearance", "hole_clearance", "hole_size", "hole_to_hole", "length", "min_resolved_spokes", "physical_clearance", "physical_hole_clearance",+    "silk_clearance", "skew", "solder_mask_expansion", "solder_paste_abs_margin", "solder_paste_rel_margin", "text_height", "text_thickness", "thermal_relief_gap",+    "thermal_spoke_width", "track_angle", "track_segment_length", "track_width", "via_count", "via_dangling", "via_diameter", "zone_connection",+];++/// Layer selectors a rule may name besides a layer name.+pub const LAYER_KEYWORDS: &[&str] = &["outer", "inner"];++/// One `(rule ...)` of a .kicad_dru.+#[derive(Clone, Debug, PartialEq)]+pub struct DruRule {+    pub name: String,+    pub layer: Option<String>,+    pub condition: Option<String>,+    pub severity: Option<String>,+    pub constraints: Vec<Value>,+    pub text: String,+    pub span: (usize, usize),+}++/// A quote-aware tokenizer that keeps whether an atom was quoted: `(`, `)`, or an atom.+#[derive(Clone, Debug, PartialEq)]+enum Tok {+    Open(usize),+    Close(usize),+    Atom(String, bool, usize),+}++fn tokens(text: &str) -> Result<Vec<Tok>, String> {+    let mut out = Vec::new();+    let b: Vec<(usize, char)> = text.char_indices().collect();+    let mut i = 0;+    while i < b.len() {+        let (pos, c) = b[i];+        match c {+            '(' => out.push(Tok::Open(pos)),+            ')' => out.push(Tok::Close(pos)),+            '"' => {+                let mut s = String::new();+                i += 1;+                let mut closed = false;+                while i < b.len() {+                    let c = b[i].1;+                    if c == '\\' && i + 1 < b.len() {+                        s.push(b[i + 1].1);+                        i += 2;+                        continue;+                    }+                    if c == '"' {+                        closed = true;+                        break;+                    }+                    s.push(c);+                    i += 1;+                }+                if !closed {+                    return Err(format!("unterminated string starting at byte {pos}"));+                }+                out.push(Tok::Atom(s, true, pos));+            }+            '#' if out.is_empty() || matches!(out.last(), Some(Tok::Close(_))) => {+                // a comment line between rules+                while i < b.len() && b[i].1 != '\n' {+                    i += 1;+                }+            }+            c if c.is_whitespace() => {}+            _ => {+                let mut s = String::new();+                while i < b.len() && !b[i].1.is_whitespace() && b[i].1 != '(' && b[i].1 != ')' && b[i].1 != '"' {+                    s.push(b[i].1);+                    i += 1;+                }+                out.push(Tok::Atom(s, false, pos));+                continue;+            }+        }+        i += 1;+    }+    Ok(out)+}++#[derive(Clone, Debug)]+enum Node {+    Atom(String, bool),+    List(Vec<Node>, usize, usize),+}++fn tree(toks: &[Tok], pos: &mut usize, text_len: usize) -> Result<Node, String> {+    match &toks[*pos] {+        Tok::Open(start) => {+            let start = *start;+            *pos += 1;+            let mut kids = Vec::new();+            loop {+                match toks.get(*pos) {+                    None => return Err(format!("unbalanced parentheses: the list opened at byte {start} is never closed")),+                    Some(Tok::Close(end)) => {+                        let end = *end;+                        *pos += 1;+                        return Ok(Node::List(kids, start, (end + 1).min(text_len)));+                    }+                    Some(_) => kids.push(tree(toks, pos, text_len)?),+                }+            }+        }+        Tok::Close(at) => Err(format!("unexpected ')' at byte {at}")),+        Tok::Atom(s, q, _) => {+            *pos += 1;+            Ok(Node::Atom(s.clone(), *q))+        }+    }+}++fn head(n: &Node) -> Option<&str> {+    match n {+        Node::List(k, _, _) => match k.first() {+            Some(Node::Atom(s, false)) => Some(s),+            _ => None,+        },+        _ => None,+    }+}++/// A KiCad rules value: a number with an optional unit (mm, mil, in, um, deg).+pub fn parse_quantity(s: &str) -> Option<f64> {+    let s = s.trim();+    for (unit, scale) in [("mm", 1.0), ("mil", 0.0254), ("um", 0.001), ("in", 25.4), ("deg", 1.0)] {+        if let Some(n) = s.strip_suffix(unit) {+            return n.trim().parse::<f64>().ok().map(|v| v * scale);+        }+    }+    s.parse::<f64>().ok()+}++/// Parse a .kicad_dru: its `(version N)` and every rule. Errors are syntax problems that+/// would make KiCad refuse the file.+pub fn parse_dru(text: &str) -> Result<(Option<i64>, Vec<DruRule>), Vec<String>> {+    let toks = tokens(text).map_err(|e| vec![e])?;+    let mut pos = 0;+    let mut nodes = Vec::new();+    while pos < toks.len() {+        match tree(&toks, &mut pos, text.len()) {+            Ok(n) => nodes.push(n),+            Err(e) => return Err(vec![e]),+        }+    }+    let mut version = None;+    let mut rules = Vec::new();+    let mut problems = Vec::new();+    for n in &nodes {+        match (head(n), n) {+            (Some("version"), Node::List(k, _, _)) => {+                version = match k.get(1) {+                    Some(Node::Atom(s, _)) => s.parse::<i64>().ok(),+                    _ => None,+                };+                if version.is_none() {+                    problems.push("(version N) needs an integer".to_string());+                }+            }+            (Some("rule"), Node::List(k, start, end)) => {+                let name = match k.get(1) {+                    Some(Node::Atom(s, _)) => s.clone(),+                    _ => {+                        problems.push(format!("a rule at byte {start} has no name"));+                        continue;+                    }+                };+                let mut rule = DruRule { name: name.clone(), layer: None, condition: None, severity: None, constraints: Vec::new(), text: text[*start..*end].to_string(), span: (*start, *end) };+                for c in &k[2..] {+                    let Node::List(kk, _, _) = c else {+                        problems.push(format!("rule {name}: stray atom"));+                        continue;+                    };+                    let atom = |i: usize| match kk.get(i) {+                        Some(Node::Atom(s, _)) => Some(s.clone()),+                        _ => None,+                    };+                    match head(c) {+                        Some("layer") => rule.layer = atom(1),+                        Some("condition") => match kk.get(1) {+                            Some(Node::Atom(s, true)) => rule.condition = Some(s.clone()),+                            _ => problems.push(format!("rule {name}: condition must be a quoted expression")),+                        },+                        Some("severity") => rule.severity = atom(1),+                        Some("constraint") => {+                            let ty = atom(1).unwrap_or_default();+                            if !CONSTRAINT_TYPES.contains(&ty.as_str()) {+                                problems.push(format!("rule {name}: unknown constraint type '{ty}'"));+                            }+                            let mut cv = json!({"type": ty});+                            for arg in &kk[2..] {+                                match arg {+                                    Node::List(a, _, _) => {+                                        let key = match a.first() {+                                            Some(Node::Atom(s, _)) => s.clone(),+                                            _ => continue,+                                        };+                                        let val = match a.get(1) {+                                            Some(Node::Atom(s, _)) => s.clone(),+                                            _ => String::new(),+                                        };+                                        if matches!(key.as_str(), "min" | "max" | "opt") && parse_quantity(&val).is_none() {+                                            problems.push(format!("rule {name}: ({key} {val}) is not a number with a unit"));+                                        }+                                        cv[key] = json!(val);+                                    }+                                    Node::Atom(s, _) => {+                                        let list = cv.get("args").and_then(Value::as_array).cloned().unwrap_or_default();+                                        let mut l = list;+                                        l.push(json!(s));+                                        cv["args"] = json!(l);+                                    }+                                }+                            }+                            rule.constraints.push(cv);+                        }+                        Some(other) => problems.push(format!("rule {name}: unknown clause ({other} ...)")),+                        None => problems.push(format!("rule {name}: a clause without a keyword")),+                    }+                }+                if rule.constraints.is_empty() {+                    problems.push(format!("rule {name}: no (constraint ...)"));+                }+                rules.push(rule);+            }+            (Some(other), _) => problems.push(format!("unknown top-level ({other} ...)")),+            _ => problems.push("a top-level atom outside any list".to_string()),+        }+    }+    if version.is_none() && !rules.is_empty() {+        problems.push("missing (version 1) at the top".to_string());+    }+    let mut seen = std::collections::HashSet::new();+    for r in &rules {+        if !seen.insert(r.name.clone()) {+            problems.push(format!("rule name '{}' appears twice", r.name));+        }+    }+    if problems.is_empty() {+        Ok((version, rules))+    } else {+        Err(problems)+    }+}++pub fn rule_json(r: &DruRule) -> Value {+    json!({"name": r.name, "layer": r.layer, "condition": r.condition, "severity": r.severity, "constraints": r.constraints, "text": r.text})+}++fn q(s: &str) -> String {+    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))+}++fn mm(v: &Value, label: &str) -> R<String> {+    match v {+        Value::Number(n) => Ok(format!("{}mm", crate::pcb::num(n.as_f64().unwrap_or(0.0)))),+        Value::String(s) if parse_quantity(s).is_some() => Ok(s.trim().to_string()),+        _ => Err(RoutingError::new("invalid_rule", format!("{label} must be a number in mm or a string with a unit like \"8mil\""))),+    }+}++/// The text of one rule from `{name, layer?, condition?, netclassPair?: [A, B], severity?,+/// constraints: [{type, min?, max?, opt?, args?}]}`, or the caller's own `text`.+pub fn rule_text(spec: &Value) -> R<String> {+    if let Some(t) = spec.get("text").and_then(Value::as_str) {+        return Ok(t.trim().to_string());+    }+    let name = spec.get("name").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()).ok_or_else(|| RoutingError::new("invalid_rule", "a rule needs a name"))?;+    let mut s = format!("(rule {}", q(name));+    if let Some(l) = spec.get("layer").and_then(Value::as_str) {+        let quoted = if LAYER_KEYWORDS.contains(&l) { l.to_string() } else { q(l) };+        s.push_str(&format!("\n\t(layer {quoted})"));+    }+    let mut cond = spec.get("condition").and_then(Value::as_str).map(str::to_string);+    if let Some(pair) = spec.get("netclassPair").and_then(Value::as_array) {+        let a = pair.first().and_then(Value::as_str).ok_or_else(|| RoutingError::new("invalid_rule", "netclassPair is [classA, classB]"))?;+        let b = pair.get(1).and_then(Value::as_str).unwrap_or(a);+        let pc = if a == b { format!("A.NetClass == '{a}' && B.NetClass == '{b}'") } else { format!("(A.NetClass == '{a}' && B.NetClass == '{b}') || (A.NetClass == '{b}' && B.NetClass == '{a}')") };+        cond = Some(match cond {+            Some(c) => format!("({c}) && ({pc})"),+            None => pc,+        });+    }+    if let Some(c) = cond {+        s.push_str(&format!("\n\t(condition {})", q(&c)));+    }+    if let Some(sv) = spec.get("severity").and_then(Value::as_str) {+        if !["error", "warning", "ignore", "exclusion"].contains(&sv) {+            return Err(RoutingError::new("invalid_rule", "severity is error, warning, ignore or exclusion"));+        }+        s.push_str(&format!("\n\t(severity {sv})"));+    }+    let cons = spec.get("constraints").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| RoutingError::new("invalid_rule", "constraints lists at least one {type, min?, max?, opt?}"))?;+    for c in cons {+        let ty = c.get("type").and_then(Value::as_str).ok_or_else(|| RoutingError::new("invalid_rule", "each constraint needs a type"))?;+        if !CONSTRAINT_TYPES.contains(&ty) {+            return Err(RoutingError::new("invalid_rule", format!("unknown constraint type {ty}")).with("known", json!(CONSTRAINT_TYPES)));+        }+        let mut line = format!("\n\t(constraint {ty}");+        for a in c.get("args").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str) {+            line.push(' ');+            line.push_str(a);+        }+        for k in ["min", "opt", "max"] {+            if let Some(v) = c.get(k) {+                let unitless = ty == "via_count" || ty == "min_resolved_spokes";+                let txt = if unitless { v.as_f64().map(|f| crate::pcb::num(f)).unwrap_or_else(|| v.as_str().unwrap_or("").to_string()) } else { mm(v, k)? };+                line.push_str(&format!(" ({k} {txt})"));+            }+        }+        line.push(')');+        s.push_str(&line);+    }+    s.push(')');+    Ok(s)+}++/// Add (or with `replace` swap in) a rule. The whole result must parse.+pub fn add_rule(text: &str, rule: &str, replace: bool) -> R<String> {+    let (_, one) = parse_dru(&format!("(version 1)\n{rule}\n")).map_err(|p| RoutingError::new("invalid_rule", "the rule does not parse").with("problems", json!(p)))?;+    let new_name = one.first().map(|r| r.name.clone()).unwrap_or_default();+    let base = if text.trim().is_empty() { "(version 1)\n".to_string() } else { text.to_string() };+    let (_, rules) = parse_dru(&base).map_err(|p| RoutingError::new("dru_invalid", "the existing .kicad_dru does not parse; fix or replace it first").with("problems", json!(p)))?;+    let out = match rules.iter().find(|r| r.name == new_name) {+        Some(old) if replace => format!("{}{}{}", &base[..old.span.0], rule, &base[old.span.1..]),+        Some(_) => return Err(RoutingError::new("rule_exists", format!("a rule named '{new_name}' already exists")).with("_hint", json!("Pass replace:true to swap it, or remove it first."))),+        None => format!("{}\n{}\n", base.trim_end(), rule),+    };+    parse_dru(&out).map_err(|p| RoutingError::new("invalid_rule", "the file would not parse with this rule").with("problems", json!(p)))?;+    Ok(out)+}++/// Remove rules by name; returns the new text and the names actually removed.+pub fn remove_rules(text: &str, names: &[String]) -> R<(String, Vec<String>)> {+    let (_, rules) = parse_dru(text).map_err(|p| RoutingError::new("dru_invalid", "the existing .kicad_dru does not parse").with("problems", json!(p)))?;+    let mut out = String::with_capacity(text.len());+    let mut last = 0;+    let mut removed = Vec::new();+    for r in &rules {+        if names.contains(&r.name) {+            out.push_str(&text[last..r.span.0]);+            last = r.span.1;+            // swallow the line break after the rule+            if text[last..].starts_with("\r\n") {+                last += 2;+            } else if text[last..].starts_with('\n') {+                last += 1;+            }+            removed.push(r.name.clone());+        }+    }+    out.push_str(&text[last..]);+    Ok((out, removed))+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn wildcards_match_like_kicad_patterns() {+        assert!(wildcard("USB_*", "USB_D+"));+        assert!(wildcard("*CLK?", "/SYS_CLK1"));+        assert!(!wildcard("USB_*", "/USB_D+"));+        assert!(wildcard("GND", "GND"));+        assert!(!wildcard("GND", "GNDA"));+        assert!(looks_regex("USB_D[PN]"));+        assert!(!looks_regex("USB_*"));+    }++    #[test]+    fn quantities_take_units() {+        assert_eq!(parse_quantity("0.2mm"), Some(0.2));+        assert!((parse_quantity("8mil").unwrap() - 0.2032).abs() < 1e-9);+        assert_eq!(parse_quantity("0.5"), Some(0.5));+        assert_eq!(parse_quantity("abc"), None);+    }++    #[test]+    fn a_rule_round_trips_through_text() {+        let t = rule_text(&json!({"name": "HV to LV", "netclassPair": ["HV", "LV"], "layer": "outer", "constraints": [{"type": "clearance", "min": 1.0}]})).unwrap();+        assert!(t.contains("(layer outer)"));+        assert!(t.contains("(constraint clearance (min 1mm))"));+        let file = add_rule("", &t, false).unwrap();+        let (v, rules) = parse_dru(&file).unwrap();+        assert_eq!(v, Some(1));+        assert_eq!(rules[0].name, "HV to LV");+        assert_eq!(rules[0].layer.as_deref(), Some("outer"));+        assert!(rules[0].condition.as_deref().unwrap().contains("A.NetClass == 'HV'"));+        assert!(add_rule(&file, &t, false).is_err());+        let replaced = add_rule(&file, &t.replace("1mm", "2mm"), true).unwrap();+        assert!(replaced.contains("2mm") && !replaced.contains("(min 1mm)"));+        let (gone, removed) = remove_rules(&replaced, &["HV to LV".into()]).unwrap();+        assert_eq!(removed, vec!["HV to LV".to_string()]);+        assert_eq!(parse_dru(&gone).unwrap().1.len(), 0);+    }++    #[test]+    fn bad_rules_are_named() {+        let p = parse_dru("(version 1)\n(rule \"a\" (constraint wiggle (min 1mm)))\n(rule \"b\" (condition A.NetClass) (constraint clearance (min x)))").unwrap_err();+        assert!(p.iter().any(|e| e.contains("unknown constraint type 'wiggle'")), "{p:?}");+        assert!(p.iter().any(|e| e.contains("condition must be a quoted")), "{p:?}");+        assert!(p.iter().any(|e| e.contains("(min x)")), "{p:?}");+        assert!(parse_dru("(version 1)\n(rule \"a\" (constraint clearance (min 1mm))").is_err());+        assert!(rule_text(&json!({"name": "x", "constraints": [{"type": "bogus"}]})).is_err());+    }+}
rust/crates/kicad-core/src/route_tools.rsadded+633
@@ -0,0 +1,633 @@+//! Planning for the copper edit verbs that `ipc::route_edit` commits live:+//! `kicad_modify_track` (width, layer or endpoints of existing tracks),+//! `kicad_route_diff_pair` (two coupled paths offset from one centerline) and+//! `kicad_copy_routing` (tracks and vias in a box, translated, nets remapped). Pure: every+//! plan carries the candidate board text the DRC preflight checks, and nothing here talks+//! to KiCad.+//!+//! Coordinates are millimetres in KiCad's board frame (+x right, +y DOWN).++use std::collections::BTreeMap;++use serde::Serialize;+use serde_json::{json, Value};++use crate::pcb::{self, Board, NetFormat, NetRef, Plan, PlanSegment, PlanVia, RoutingError};+use crate::project_rules;++type R<T> = Result<T, RoutingError>;++/// Most copper items one call may create or change (one undo step).+pub const MAX_ITEMS: usize = 1024;++// ---------------------------------------------------------------------------+// Board text helpers+// ---------------------------------------------------------------------------++/// Byte spans of the root list's direct children, quotes respected.+pub fn top_level_spans(text: &str) -> Vec<(usize, usize)> {+    let mut spans = Vec::new();+    let (mut depth, mut quoted, mut escaped, mut start) = (0i32, false, false, 0usize);+    for (i, c) in text.char_indices() {+        if quoted {+            if escaped {+                escaped = false+            } else if c == '\\' {+                escaped = true+            } else if c == '"' {+                quoted = false+            }+            continue;+        }+        match c {+            '"' => quoted = true,+            '(' => {+                depth += 1;+                if depth == 2 {+                    start = i;+                }+            }+            ')' => {+                if depth == 2 {+                    spans.push((start, i + 1));+                }+                depth -= 1;+            }+            _ => {}+        }+    }+    spans+}++/// The span of the top-level item of one of `kinds` carrying this uuid.+pub fn item_span(text: &str, kinds: &[&str], id: &str) -> Option<(usize, usize)> {+    let a = format!("(uuid \"{id}\")");+    let b = format!("(tstamp \"{id}\")");+    top_level_spans(text).into_iter().find(|(s, e)| {+        let item = &text[*s..*e];+        kinds.iter().any(|k| item.starts_with(&format!("({k}")) && item[k.len() + 1..].starts_with(|c: char| c.is_whitespace())) && (item.contains(&a) || item.contains(&b))+    })+}++/// A net this board can write, from a name (KiCad 10) or a number (older boards).+pub fn net_ref(board: &Board, number: Option<i64>, name: Option<&str>) -> NetRef {+    let name = name.map(str::to_string).or_else(|| number.and_then(|n| board.nets.iter().find(|x| x.number == Some(n)).map(|x| x.name.clone()))).unwrap_or_default();+    let number = number.or_else(|| board.nets.iter().find(|x| x.name == name).and_then(|x| x.number));+    NetRef { format: board.net_format, number, name: name.clone(), key: pcb::net_key(board, number, Some(&name)) }+}++fn named_net(board: &Board, name: &str) -> R<NetRef> {+    let hits: Vec<&pcb::Net> = board.nets.iter().filter(|n| n.name == name).collect();+    if hits.len() != 1 {+        let mut near: Vec<&str> = board.nets.iter().map(|n| n.name.as_str()).filter(|n| n.to_lowercase().contains(&name.to_lowercase())).collect();+        near.truncate(20);+        return Err(RoutingError::new("unknown_net", format!("No unique net named '{name}'")).with("candidates", json!(near)));+    }+    Ok(net_ref(board, hits[0].number, Some(name)))+}++fn point(v: &Value, pads: &[pcb::Pad], label: &str) -> R<(f64, f64)> {+    pcb::resolve_point(v, pads).map(|(p, _)| p).map_err(|e| RoutingError::new(e.get("errorCode").and_then(Value::as_str).unwrap_or("bad_point"), format!("{label}: {}", e.get("error").and_then(Value::as_str).unwrap_or("bad point"))))+}++fn path_length(segments: &[PlanSegment]) -> f64 {+    segments.iter().map(|s| pcb::dist((s.start[0], s.start[1]), (s.end[0], s.end[1]))).sum()+}++fn r4(v: f64) -> f64 {+    (v * 10_000.0).round() / 10_000.0+}++// ---------------------------------------------------------------------------+// kicad_modify_track+// ---------------------------------------------------------------------------++#[derive(Clone, Debug, PartialEq, Serialize)]+pub struct TrackGeom {+    pub start: [f64; 2],+    pub end: [f64; 2],+    pub width: f64,+    pub layer: String,+}++#[derive(Clone, Debug, Serialize)]+pub struct TrackChange {+    pub id: String,+    pub net: String,+    pub before: TrackGeom,+    pub after: TrackGeom,+    pub changed: Vec<&'static str>,+}++/// Read `changes: [{id, width?, layer?, start?, end?}]`, or `itemIds` (or `id`) with a+/// shared `width` / `layer` (and `start` / `end` for a single id). Endpoints take [x,y],+/// {x,y} or a pad name. Returns the changes and the candidate board text with every+/// changed segment rewritten in place (same uuid, same net).+pub fn plan_track_changes(board: &Board, text: &str, args: &Value) -> R<(Vec<TrackChange>, String)> {+    let rows: Vec<Value> = if let Some(Value::Array(c)) = args.get("changes") {+        c.clone()+    } else {+        let ids: Vec<String> = match (args.get("itemIds"), args.get("id")) {+            (Some(Value::Array(a)), _) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),+            (_, Some(Value::String(s))) => vec![s.clone()],+            _ => Vec::new(),+        };+        if ids.len() > 1 && (args.get("start").is_some() || args.get("end").is_some()) {+            return Err(RoutingError::new("invalid_changes", "start and end apply to one track; use changes:[{id,start,end}] for several"));+        }+        ids.into_iter().map(|id| json!({"id": id, "width": args.get("width"), "layer": args.get("layer"), "start": args.get("start"), "end": args.get("end")})).collect()+    };+    if rows.is_empty() || rows.len() > MAX_ITEMS {+        return Err(RoutingError::new("invalid_changes", format!("Pass changes:[{{id, width?, layer?, start?, end?}}] (1..{MAX_ITEMS}) or itemIds with width/layer")));+    }+    let pads = pcb::pads(board);+    let copper = pcb::copper_layers(board);+    let mut changes: Vec<TrackChange> = Vec::new();+    let mut out = text.to_string();+    for row in &rows {+        let id = row.get("id").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(|| RoutingError::new("invalid_changes", "every change needs the track id (kicad_routing_state segments[].uuid)"))?;+        if changes.iter().any(|c| c.id == id) {+            return Err(RoutingError::new("invalid_changes", format!("track {id} appears twice")));+        }+        let Some(seg) = board.segments.iter().find(|s| s.uuid.as_deref() == Some(id)) else {+            let is_arc = item_span(text, &["arc"], id).is_some();+            let is_via = board.vias.iter().any(|v| v.uuid.as_deref() == Some(id));+            let why = if is_arc { "is an arc; only straight tracks are modified (remove and route it again)" } else if is_via { "is a via, not a track" } else { "is not a track on this board" };+            return Err(RoutingError::new("unknown_track", format!("{id} {why}")).with("mutated", json!(false)));+        };+        let (Some(s), Some(e), Some(w), Some(l)) = (&seg.start, &seg.end, seg.width, &seg.layer) else {+            return Err(RoutingError::new("unknown_track", format!("{id} has incomplete geometry")));+        };+        let before = TrackGeom { start: [s.x, s.y], end: [e.x, e.y], width: w, layer: l.clone() };+        let mut after = before.clone();+        let mut changed = Vec::new();+        if let Some(v) = row.get("width").filter(|v| !v.is_null()) {+            after.width = pcb::number(Some(v), "width", true)?;+        }+        if let Some(v) = row.get("layer").filter(|v| !v.is_null()) {+            let layer = v.as_str().ok_or_else(|| RoutingError::new("unknown_layer", "layer is a copper layer name"))?;+            if !copper.iter().any(|c| c == layer) {+                return Err(RoutingError::new("unknown_layer", format!("Unknown copper layer: {layer}")).with("copperLayers", json!(copper)));+            }+            after.layer = layer.to_string();+        }+        if let Some(v) = row.get("start").filter(|v| !v.is_null()) {+            let p = point(v, &pads, "start")?;+            after.start = [pcb::round6(p.0), pcb::round6(p.1)];+        }+        if let Some(v) = row.get("end").filter(|v| !v.is_null()) {+            let p = point(v, &pads, "end")?;+            after.end = [pcb::round6(p.0), pcb::round6(p.1)];+        }+        if after.start == after.end {+            return Err(RoutingError::new("zero_length", format!("track {id} would have zero length")));+        }+        if after.width != before.width {+            changed.push("width");+        }+        if after.layer != before.layer {+            changed.push("layer");+        }+        if after.start != before.start {+            changed.push("start");+        }+        if after.end != before.end {+            changed.push("end");+        }+        if changed.is_empty() {+            return Err(RoutingError::new("no_change", format!("track {id} already has that geometry")).with("track", json!(before)).with("mutated", json!(false)));+        }+        let span = item_span(&out, &["segment"], id).ok_or_else(|| RoutingError::new("unknown_track", format!("{id} was not found in the board text")))?;+        let old = &out[span.0..span.1];+        if old.contains("(locked yes)") || old.contains("(locked)") {+            if args.get("allowLocked") != Some(&json!(true)) {+                return Err(RoutingError::new("track_locked", format!("track {id} is locked; pass allowLocked:true to change it anyway")).with("mutated", json!(false)));+            }+        }+        let net = net_ref(board, seg.net, seg.net_name.as_deref());+        let mut block = pcb::segment_sexpr((after.start[0], after.start[1]), (after.end[0], after.end[1]), after.width, &after.layer, &net);+        if let Some(i) = block.find("(uuid \"") {+            let j = block[i + 7..].find('"').map(|j| i + 7 + j).unwrap_or(block.len());+            block.replace_range(i + 7..j, id);+        }+        out.replace_range(span.0..span.1, block.trim_start());+        changes.push(TrackChange { id: id.to_string(), net: net.name.clone(), before, after, changed });+    }+    Ok((changes, out))+}++// ---------------------------------------------------------------------------+// kicad_route_diff_pair+// ---------------------------------------------------------------------------++/// The P and N nets of a pair named by its base (`USB_D` -> `USB_D+`/`USB_D-`, `_P`/`_N`,+/// `P`/`N`, `_DP`/`_DN`), or the partner of a P net.+pub fn pair_nets(board: &Board, args: &Value) -> R<(String, String)> {+    let has = |n: &str| board.nets.iter().any(|x| x.name == n);+    let p = args.get("netP").and_then(Value::as_str);+    let n = args.get("netN").and_then(Value::as_str);+    if let (Some(p), Some(n)) = (p, n) {+        return Ok((p.to_string(), n.to_string()));+    }+    const SUFFIXES: &[(&str, &str)] = &[("+", "-"), ("_P", "_N"), ("_DP", "_DN"), ("P", "N"), ("_p", "_n")];+    if let Some(p) = p {+        for (a, b) in SUFFIXES {+            if let Some(base) = p.strip_suffix(a) {+                let cand = format!("{base}{b}");+                if has(&cand) {+                    return Ok((p.to_string(), cand));+                }+            }+        }+        return Err(RoutingError::new("pair_not_found", format!("no N partner found for {p}; pass netN")));+    }+    if let Some(base) = args.get("pair").and_then(Value::as_str) {+        for (a, b) in SUFFIXES {+            let (cp, cn) = (format!("{base}{a}"), format!("{base}{b}"));+            if has(&cp) && has(&cn) {+                return Ok((cp, cn));+            }+        }+        return Err(RoutingError::new("pair_not_found", format!("no P/N nets found for the pair {base} (tried +/-, _P/_N, _DP/_DN, P/N)")));+    }+    Err(RoutingError::new("missing_net", "Pass netP and netN, netP alone, or pair (the base name)"))+}++/// Offset a polyline sideways by `d[i]` at vertex i (positive = left of the direction of+/// travel, in the y-down frame). Interior vertices use the miter of the two adjacent+/// normals; a corner sharper than about 130 degrees of turn is refused.+pub fn offset_path(points: &[(f64, f64)], d: &[f64]) -> R<Vec<(f64, f64)>> {+    let n = points.len();+    let normal = |a: (f64, f64), b: (f64, f64)| -> R<(f64, f64)> {+        let (dx, dy) = (b.0 - a.0, b.1 - a.1);+        let len = (dx * dx + dy * dy).sqrt();+        if len < 1e-9 {+            return Err(RoutingError::new("invalid_geometry", "the centerline repeats a point"));+        }+        Ok((dy / len, -dx / len))+    };+    let mut out = Vec::with_capacity(n);+    for i in 0..n {+        let (nx, ny) = if i == 0 {+            normal(points[0], points[1])?+        } else if i == n - 1 {+            normal(points[n - 2], points[n - 1])?+        } else {+            let a = normal(points[i - 1], points[i])?;+            let b = normal(points[i], points[i + 1])?;+            let (mx, my) = (a.0 + b.0, a.1 + b.1);+            let ml = (mx * mx + my * my).sqrt();+            if ml < 0.85 {+                return Err(RoutingError::new("corner_too_sharp", format!("the centerline turns too sharply at point {i}; split the corner into two 45-degree bends")));+            }+            let (ux, uy) = (mx / ml, my / ml);+            let scale = 1.0 / (ux * a.0 + uy * a.1);+            (ux * scale, uy * scale)+        };+        out.push((pcb::round6(points[i].0 + nx * d[i]), pcb::round6(points[i].1 + ny * d[i])));+    }+    Ok(out)+}++#[derive(Clone, Debug)]+pub struct DiffPairPlan {+    pub p: Plan,+    pub n: Plan,+    pub width: f64,+    pub gap: f64,+    pub via_gap: f64,+    pub length_p: f64,+    pub length_n: f64,+    pub source: &'static str,+}++impl DiffPairPlan {+    pub fn public(&self) -> Value {+        let skew = r4((self.length_p - self.length_n).abs());+        json!({+            "netP": self.p.net_name, "netN": self.n.net_name,+            "width": self.width, "gap": self.gap, "viaGap": self.via_gap, "sizesFrom": self.source,+            "p": self.p.public(), "n": self.n.public(),+            "lengthP": r4(self.length_p), "lengthN": r4(self.length_n), "skewMm": skew,+            "longer": if skew == 0.0 { Value::Null } else if self.length_p > self.length_n { json!(self.p.net_name) } else { json!(self.n.net_name) },+            "viaPairs": self.p.vias.len(),+        })+    }+    pub fn blocks(&self) -> Vec<String> {+        self.p.blocks.iter().chain(self.n.blocks.iter()).cloned().collect()+    }+}++/// Plan both halves of a pair: `centerline: [[x,y] | {x,y,layer?}, ...]` (a `layer` on a+/// point changes layer there, with a via pair), `gap` and `width` (default: the P net's+/// netclass diff pair values from the project, else 0.25 / 0.2), `pSide: left|right`,+/// optional `pStart`, `nStart`, `pEnd`, `nEnd` pads joined to the offset ends, and the+/// usual `layer`, `viaSize`, `viaDrill`. Each half goes through `pcb::plan`, the planner+/// `kicad_route_net` uses, so its pad, net and layer checks hold for both.+pub fn plan_diff_pair(board: &Board, project: Option<&Value>, args: &Value) -> R<DiffPairPlan> {+    let (net_p, net_n) = pair_nets(board, args)?;+    if net_p == net_n {+        return Err(RoutingError::new("invalid_pair", "netP and netN must differ"));+    }+    named_net(board, &net_p)?;+    named_net(board, &net_n)?;+    let class = project.map(|p| project_rules::class_for_net(p, &net_p));+    let from_class = |k: &str| class.as_ref().and_then(|c| c.get(k)).and_then(Value::as_f64).filter(|v| *v > 0.0);+    let source = if class.is_some() && args.get("gap").is_none() && args.get("width").is_none() { "netclass" } else if args.get("gap").is_some() && args.get("width").is_some() { "arguments" } else if class.is_some() { "arguments+netclass" } else { "defaults" };+    let width = match args.get("width") {+        Some(v) => pcb::number(Some(v), "width", true)?,+        None => from_class("diff_pair_width").unwrap_or(0.2),+    };+    let gap = match args.get("gap") {+        Some(v) => pcb::number(Some(v), "gap", true)?,+        None => from_class("diff_pair_gap").unwrap_or(0.25),+    };+    let via_size = pcb::number(Some(args.get("viaSize").unwrap_or(&json!(0.6))), "viaSize", true)?;+    let via_drill = pcb::number(Some(args.get("viaDrill").unwrap_or(&json!(0.3))), "viaDrill", true)?;+    let via_gap = match args.get("viaGap") {+        Some(v) => pcb::number(Some(v), "viaGap", true)?,+        None => from_class("diff_pair_via_gap").unwrap_or(gap),+    };+    let pads = pcb::pads(board);+    let line = args.get("centerline").and_then(Value::as_array).filter(|a| (2..=256).contains(&a.len())).ok_or_else(|| RoutingError::new("invalid_path", "centerline needs 2..256 points: [x,y] or {x,y,layer?}"))?;+    let mut pts = Vec::with_capacity(line.len());+    let mut layers: Vec<Option<String>> = Vec::with_capacity(line.len());+    for (i, spec) in line.iter().enumerate() {+        if spec.get("pad").is_some() || spec.is_string() {+            return Err(RoutingError::new("invalid_path", "centerline points are coordinates; join pads with pStart/nStart/pEnd/nEnd"));+        }+        pts.push(point(spec, &pads, &format!("centerline[{i}]"))?);+        layers.push(spec.get("layer").and_then(Value::as_str).map(str::to_string));+    }+    let half = (gap + width) / 2.0;+    let half_via = half.max((via_size + via_gap) / 2.0);+    let sign = match args.get("pSide").and_then(Value::as_str) {+        None | Some("left") => 1.0,+        Some("right") => -1.0,+        Some(o) => return Err(RoutingError::new("invalid_side", format!("pSide is left or right, not {o}"))),+    };+    let d: Vec<f64> = (0..pts.len()).map(|i| if layers[i].is_some() { half_via } else { half }).collect();+    let left = offset_path(&pts, &d)?;+    let right = offset_path(&pts, &d.iter().map(|v| -v).collect::<Vec<_>>())?;+    let (p_pts, n_pts) = if sign > 0.0 { (left, right) } else { (right, left) };+    let build = |net: &str, offs: &[(f64, f64)], start: Option<&Value>, end: Option<&Value>| -> R<Plan> {+        let mut path: Vec<Value> = Vec::new();+        if let Some(s) = start {+            path.push(s.clone());+        }+        for (i, p) in offs.iter().enumerate() {+            let mut v = json!({"x": p.0, "y": p.1});+            if let Some(l) = &layers[i] {+                v["layer"] = json!(l);+            }+            path.push(v);+        }+        if let Some(e) = end {+            path.push(e.clone());+        }+        let mut a = json!({"net": net, "points": path, "width": width, "viaSize": via_size, "viaDrill": via_drill});+        if let Some(l) = args.get("layer") {+            a["layer"] = l.clone();+        }+        pcb::plan(board, &a).map_err(|e| {+            let mut e2 = RoutingError::new(&e.code, format!("{net}: {}", e.message));+            e2.detail = e.detail;+            e2+        })+    };+    let p = build(&net_p, &p_pts, args.get("pStart"), args.get("pEnd"))?;+    let n = build(&net_n, &n_pts, args.get("nStart"), args.get("nEnd"))?;+    if p.segments.len() + p.vias.len() + n.segments.len() + n.vias.len() > MAX_ITEMS {+        return Err(RoutingError::new("route_too_large", format!("Limit each call to {MAX_ITEMS} copper items")));+    }+    let (length_p, length_n) = (path_length(&p.segments), path_length(&n.segments));+    Ok(DiffPairPlan { p, n, width, gap, via_gap, length_p, length_n, source })+}++// ---------------------------------------------------------------------------+// kicad_copy_routing+// ---------------------------------------------------------------------------++#[derive(Clone, Debug)]+pub struct CopyPlan {+    pub plans: Vec<Plan>,+    pub offset: (f64, f64),+    pub net_map: BTreeMap<String, String>,+    pub skipped: Vec<Value>,+    pub source_ids: Vec<String>,+    pub ref_map: BTreeMap<String, String>,+}++impl CopyPlan {+    pub fn blocks(&self) -> Vec<String> {+        self.plans.iter().flat_map(|p| p.blocks.iter().cloned()).collect()+    }+    pub fn public(&self) -> Value {+        json!({+            "offset": {"dx": self.offset.0, "dy": self.offset.1},+            "netMap": self.net_map, "refMap": self.ref_map,+            "segmentCount": self.plans.iter().map(|p| p.segments.len()).sum::<usize>(),+            "viaCount": self.plans.iter().map(|p| p.vias.len()).sum::<usize>(),+            "sourceItemIds": self.source_ids,+            "skipped": self.skipped,+            "byNet": self.plans.iter().map(|p| p.public()).collect::<Vec<_>>(),+        })+    }+}++/// `*` in `from` captures; the capture fills the `*` in `to`. Without a star, a plain+/// substring replacement.+pub fn map_ref(reference: &str, from: &str, to: &str) -> Option<String> {+    match from.split_once('*') {+        Some((pre, post)) => {+            if reference.len() >= pre.len() + post.len() && reference.starts_with(pre) && reference.ends_with(post) {+                let mid = &reference[pre.len()..reference.len() - post.len()];+                Some(if to.contains('*') { to.replacen('*', mid, 1) } else { format!("{to}{mid}") })+            } else {+                None+            }+        }+        None => reference.contains(from).then(|| reference.replacen(from, to, 1)),+    }+}++fn rect_arg(v: Option<&Value>) -> R<(f64, f64, f64, f64)> {+    let v = v.ok_or_else(|| RoutingError::new("missing_arg", "source is the box to copy from: {minX, minY, maxX, maxY} in mm"))?;+    let g = |k: &str| pcb::number(v.get(k), k, false);+    let (a, b, c, d) = (g("minX")?, g("minY")?, g("maxX")?, g("maxY")?);+    if c <= a || d <= b {+        return Err(RoutingError::new("invalid_geometry", "source box needs maxX > minX and maxY > minY"));+    }+    Ok((a, b, c, d))+}++/// Copy every track (both ends inside) and via (centre inside) of `source` by `offset`+/// ({dx, dy} or [dx, dy]). Nets: `netMap` {from: to} wins; else `refMap` {"U1": "U3"} or+/// `refPattern` {from: "CH1_*", to: "CH2_*"} pairs footprints and maps every source pad's+/// net to the same pad number's net on its partner. With a remap in play an item whose+/// net has no mapping is skipped (`keepUnmappedNets:true` keeps its net).+pub fn plan_copy(board: &Board, args: &Value) -> R<CopyPlan> {+    let (x0, y0, x1, y1) = rect_arg(args.get("source"))?;+    let off = args.get("offset").ok_or_else(|| RoutingError::new("missing_arg", "offset is {dx, dy} in mm"))?;+    let (dx, dy) = match off {+        Value::Array(a) if a.len() == 2 => (pcb::number(a.first(), "dx", false)?, pcb::number(a.get(1), "dy", false)?),+        _ => (pcb::number(off.get("dx"), "dx", false)?, pcb::number(off.get("dy"), "dy", false)?),+    };+    if dx == 0.0 && dy == 0.0 {+        return Err(RoutingError::new("invalid_geometry", "offset is zero: the copy would sit on the original"));+    }+    let layers: Option<Vec<String>> = args.get("layers").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());+    // footprint pairs+    let mut ref_map: BTreeMap<String, String> = BTreeMap::new();+    if let Some(m) = args.get("refMap").and_then(Value::as_object) {+        for (k, v) in m {+            if let Some(t) = v.as_str() {+                ref_map.insert(k.clone(), t.to_string());+            }+        }+    }+    if let Some(p) = args.get("refPattern") {+        let from = p.get("from").and_then(Value::as_str).ok_or_else(|| RoutingError::new("invalid_ref_pattern", "refPattern is {from: \"CH1_*\", to: \"CH2_*\"}"))?;+        let to = p.get("to").and_then(Value::as_str).ok_or_else(|| RoutingError::new("invalid_ref_pattern", "refPattern is {from, to}"))?;+        for fp in &board.footprints {+            if let Some(t) = map_ref(&fp.reference, from, to) {+                if t != fp.reference && board.footprints.iter().any(|f| f.reference == t) {+                    ref_map.entry(fp.reference.clone()).or_insert(t);+                }+            }+        }+        if ref_map.is_empty() {+            return Err(RoutingError::new("no_ref_pairs", format!("refPattern {from} -> {to} pairs no two footprints on this board")));+        }+    }+    let pads = pcb::pads(board);+    let mut net_map: BTreeMap<String, String> = BTreeMap::new();+    let mut conflicts: Vec<Value> = Vec::new();+    for (src, dst) in &ref_map {+        if !board.footprints.iter().any(|f| &f.reference == dst) {+            return Err(RoutingError::new("unknown_footprint", format!("refMap target {dst} is not on this board")));+        }+        for p in pads.iter().filter(|p| &p.reference == src && !p.net_name.is_empty()) {+            let Some(q) = pads.iter().find(|q| &q.reference == dst && q.pad == p.pad) else { continue };+            match net_map.get(&p.net_name) {+                Some(existing) if existing != &q.net_name => conflicts.push(json!({"net": p.net_name, "maps": [existing, q.net_name], "via": format!("{src}.{} -> {dst}.{}", p.pad, q.pad)})),+                Some(_) => {}+                None => {+                    if !q.net_name.is_empty() {+                        net_map.insert(p.net_name.clone(), q.net_name.clone());+                    }+                }+            }+        }+    }+    if let Some(m) = args.get("netMap").and_then(Value::as_object) {+        for (k, v) in m {+            if let Some(t) = v.as_str() {+                named_net(board, t)?;+                net_map.insert(k.clone(), t.to_string());+            }+        }+    }+    if !conflicts.is_empty() && args.get("netMap").is_none() {+        return Err(RoutingError::new("net_map_conflict", "the footprint pairs map one source net to more than one target net").with("conflicts", json!(conflicts)).with("_hint", json!("Pass netMap {from: to} for the nets named in conflicts; it overrides the pad-derived mapping.")));+    }+    let remapping = !ref_map.is_empty() || args.get("netMap").is_some();+    let keep = args.get("keepUnmappedNets").and_then(Value::as_bool).unwrap_or(!remapping);+    let inside = |x: f64, y: f64| x >= x0 - 1e-9 && x <= x1 + 1e-9 && y >= y0 - 1e-9 && y <= y1 + 1e-9;+    let layer_ok = |l: &str| layers.as_ref().map(|ls| ls.iter().any(|x| x == l)).unwrap_or(true);+    let target_net = |name: &str| -> Option<String> {+        match net_map.get(name) {+            Some(t) => Some(t.clone()),+            None if keep => Some(name.to_string()),+            None => None,+        }+    };+    let mut by_net: BTreeMap<String, Plan> = BTreeMap::new();+    let mut skipped = Vec::new();+    let mut source_ids = Vec::new();+    let mut count = 0usize;+    for s in &board.segments {+        let (Some(a), Some(b), Some(w), Some(l)) = (&s.start, &s.end, s.width, &s.layer) else { continue };+        if !(inside(a.x, a.y) && inside(b.x, b.y)) || !layer_ok(l) {+            continue;+        }+        let name = s.net_name.clone().unwrap_or_default();+        let id = s.uuid.clone().unwrap_or_default();+        let Some(t) = target_net(&name) else {+            skipped.push(json!({"id": id, "kind": "track", "net": name, "reason": "unmapped_net"}));+            continue;+        };+        let net = net_ref(board, None, Some(&t));+        let (na, nb) = ((pcb::round6(a.x + dx), pcb::round6(a.y + dy)), (pcb::round6(b.x + dx), pcb::round6(b.y + dy)));+        let plan = by_net.entry(t.clone()).or_insert_with(|| Plan { net_name: t.clone(), segments: Vec::new(), vias: Vec::new(), blocks: Vec::new() });+        plan.segments.push(PlanSegment { start: [na.0, na.1], end: [nb.0, nb.1], layer: l.clone(), width: w });+        plan.blocks.push(pcb::segment_sexpr(na, nb, w, l, &net));+        source_ids.push(id);+        count += 1;+    }+    for v in &board.vias {+        let (Some(p), Some(size), Some(drill)) = (&v.position, v.size, v.drill) else { continue };+        if !inside(p.x, p.y) {+            continue;+        }+        let name = v.net_name.clone().unwrap_or_default();+        let id = v.uuid.clone().unwrap_or_default();+        let mut ls = v.layers.clone().unwrap_or_default();+        ls.sort();+        if ls != ["B.Cu".to_string(), "F.Cu".to_string()] || v.kind.as_deref().map(|k| k != "through" && !k.is_empty()).unwrap_or(false) {+            skipped.push(json!({"id": id, "kind": "via", "net": name, "reason": "not_a_through_via"}));+            continue;+        }+        let Some(t) = target_net(&name) else {+            skipped.push(json!({"id": id, "kind": "via", "net": name, "reason": "unmapped_net"}));+            continue;+        };+        let net = net_ref(board, None, Some(&t));+        let at = (pcb::round6(p.x + dx), pcb::round6(p.y + dy));+        let plan = by_net.entry(t.clone()).or_insert_with(|| Plan { net_name: t.clone(), segments: Vec::new(), vias: Vec::new(), blocks: Vec::new() });+        plan.vias.push(PlanVia { at: [at.0, at.1], layers: ["F.Cu".into(), "B.Cu".into()], size, drill });+        plan.blocks.push(pcb::via_sexpr(at, size, drill, ("F.Cu", "B.Cu"), &net));+        source_ids.push(id);+        count += 1;+    }+    if count == 0 {+        return Err(RoutingError::new("nothing_to_copy", "no track or via lies inside the source box").with("skipped", json!(skipped)));+    }+    if count > MAX_ITEMS {+        return Err(RoutingError::new("route_too_large", format!("{count} items; limit each call to {MAX_ITEMS}")));+    }+    if board.net_format == NetFormat::Number {+        for t in by_net.keys() {+            if !t.is_empty() && !board.nets.iter().any(|n| &n.name == t) {+                return Err(RoutingError::new("unknown_net", format!("target net {t} is not on this board")));+            }+        }+    }+    Ok(CopyPlan { plans: by_net.into_values().collect(), offset: (dx, dy), net_map, skipped, source_ids, ref_map })+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn offsets_keep_a_constant_distance_on_straight_runs_and_miter_at_corners() {+        let pts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)];+        let left = offset_path(&pts, &[1.0, 1.0, 1.0]).unwrap();+        // left of +x travel in a y-down frame is -y+        assert_eq!(left[0], (0.0, -1.0));+        assert_eq!(left[1], (11.0, -1.0));+        assert_eq!(left[2], (11.0, 10.0));+        let right = offset_path(&pts, &[-1.0, -1.0, -1.0]).unwrap();+        assert_eq!(right[1], (9.0, 1.0));+        assert!(offset_path(&[(0.0, 0.0), (10.0, 0.0), (0.0, 0.1)], &[1.0; 3]).is_err());+    }++    #[test]+    fn reference_patterns_capture_the_star() {+        assert_eq!(map_ref("CH1_R3", "CH1_*", "CH2_*").as_deref(), Some("CH2_R3"));+        assert_eq!(map_ref("R13", "R1*", "R2*").as_deref(), Some("R23"));+        assert_eq!(map_ref("U1", "CH1_*", "CH2_*"), None);+        assert_eq!(map_ref("U1A", "1", "2").as_deref(), Some("U2A"));+    }+}
rust/crates/kicad-core/src/sch_check.rsadded+474
@@ -0,0 +1,474 @@+//! Schematic analysis on one sheet: what is connected at a point, the connectivity and+//! layout check (dangling wire ends, floating labels, unconnected pins, single-pin nets,+//! distinct names shorted together, overlapping symbols and text), and the near-miss wire+//! end fixer. Everything here reads `sch_model`'s model; only `apply_moves` edits a file.++use serde::Serialize;+use serde_json::{json, Value};++use crate::schematic::{Node, Schematic};+use crate::sch_model::{dist, on_interior, on_segment, overlap, r4, same, BBox, Connectivity, Item, Pt, SheetModel};++#[derive(Clone, Debug, Serialize)]+pub struct Finding {+    pub check: &'static str,+    /// error | warning | info+    pub severity: &'static str,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub at: Option<[f64; 2]>,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub uuid: Option<String>,+    #[serde(skip_serializing_if = "Vec::is_empty")]+    pub refs: Vec<String>,+    #[serde(skip_serializing_if = "Vec::is_empty")]+    pub nets: Vec<String>,+    pub message: String,+    pub fix: String,+}++/// `U1.3 EN`, or `R1.2` when the pin has no name of its own.+fn pin_label(p: &crate::sch_model::Pin) -> String {+    if p.name.is_empty() || p.name == "~" || p.name == p.number {+        format!("{}.{}", p.reference, p.number)+    } else {+        format!("{}.{} {}", p.reference, p.number, p.name)+    }+}++fn pt(p: Pt) -> Option<[f64; 2]> {+    Some([r4(p.0), r4(p.1)])+}++fn fmt(p: Pt) -> String {+    format!("({}, {})", crate::schematic::fmt_num(r4(p.0)), crate::schematic::fmt_num(r4(p.1)))+}++/// Is a wire end at `p` (of segment `seg`) terminated by anything other than itself?+fn end_terminated(m: &SheetModel, seg: usize, p: Pt) -> bool {+    m.wires.iter().enumerate().any(|(j, w)| j != seg && (same(w.a, p) || same(w.b, p) || on_interior(p, w.a, w.b)))+        || m.pins.iter().any(|x| same(x.at, p))+        || m.labels.iter().any(|x| same(x.at, p))+        || m.sheet_pins().any(|x| same(x.at, p))+        || m.junctions.iter().any(|x| same(x.at, p))+        || m.no_connects.iter().any(|x| same(x.at, p))+        || m.buses.iter().any(|b| on_segment(p, b.a, b.b))+}++/// Every wire end nothing terminates: (segment index, end point, the other end).+pub fn dangling_ends(m: &SheetModel) -> Vec<(usize, Pt, Pt)> {+    let mut out = Vec::new();+    for (i, w) in m.wires.iter().enumerate() {+        for (p, other) in [(w.a, w.b), (w.b, w.a)] {+            if !end_terminated(m, i, p) {+                out.push((i, p, other));+            }+        }+    }+    out+}++/// A point item physically touches a wire end, or mid-wire through a junction dot (pins and+/// sheet pins), or anywhere along a wire (labels).+fn on_wire(m: &SheetModel, p: Pt, anywhere: bool) -> bool {+    m.wires.iter().any(|w| same(w.a, p) || same(w.b, p) || ((anywhere || m.junctions.iter().any(|j| same(j.at, p))) && on_segment(p, w.a, w.b)))+}++/// The nearest connectable thing to `p` within `tol` (not on wire segment `skip`):+/// (point, description).+pub fn nearest_target(m: &SheetModel, p: Pt, tol: f64, skip_uuid: &str) -> Option<(Pt, String)> {+    let mut best: Option<(f64, Pt, String)> = None;+    let mut consider = |q: Pt, what: String| {+        let d = dist(p, q);+        if d > 1e-6 && d <= tol && best.as_ref().map_or(true, |b| d < b.0) {+            best = Some((d, q, what));+        }+    };+    for x in &m.pins {+        consider(x.at, if x.power_symbol { format!("power symbol {} ({})", x.reference, x.power_net.clone().unwrap_or_default()) } else { format!("pin {}.{}", x.reference, x.number) });+    }+    for x in &m.labels {+        consider(x.at, format!("{} label {}", x.kind, x.text));+    }+    for x in m.sheet_pins() {+        consider(x.at, format!("sheet pin {} of {}", x.name, x.sheet_name));+    }+    for w in m.wires.iter().filter(|w| w.uuid != skip_uuid) {+        consider(w.a, format!("wire end {}", w.uuid));+        consider(w.b, format!("wire end {}", w.uuid));+    }+    best.map(|(_, q, w)| (q, w))+}++#[derive(Clone, Debug, Default)]+pub struct CheckOpts {+    pub overlaps: bool,+}++pub struct CheckReport {+    pub findings: Vec<Finding>,+    /// Nets that carry a global, hierarchical or power name: single-pin judgement needs the+    /// other sheets, so they are not flagged here.+    pub cross_sheet_skipped: usize,+}++pub fn check(m: &SheetModel, c: &Connectivity, opts: &CheckOpts) -> CheckReport {+    let mut f: Vec<Finding> = Vec::new();++    // dangling wire ends+    for (seg, p, _) in dangling_ends(m) {+        let w = &m.wires[seg];+        let near = nearest_target(m, p, 1.27, &w.uuid);+        let (message, fix) = match &near {+            Some((q, what)) => (+                format!("Wire end at {} connects to nothing; {what} is {} mm away at {}", fmt(p), crate::schematic::fmt_num(r4(dist(p, *q))), fmt(*q)),+                "A near miss: kicad_sch_fix_connectivity {dryRun:false} snaps it onto that point (tolerance 0.5 mm by default; pass tolerance to widen).".to_string(),+            ),+            None => (+                format!("Wire end at {} connects to nothing", fmt(p)),+                "Extend the wire onto the pin or label it should reach, or delete the stub with kicad_sch_delete {uuid}.".to_string(),+            ),+        };+        f.push(Finding { check: "dangling_wire_end", severity: "warning", at: pt(p), uuid: Some(w.uuid.clone()), refs: vec![], nets: vec![], message, fix });+    }++    // floating labels+    for l in &m.labels {+        let touches = on_wire(m, l.at, true) || m.pins.iter().any(|p| same(p.at, l.at)) || m.sheet_pins().any(|s| same(s.at, l.at));+        if !touches {+            f.push(Finding {+                check: "floating_label",+                severity: "error",+                at: pt(l.at),+                uuid: Some(l.uuid.clone()),+                refs: vec![],+                nets: vec![l.text.clone()],+                message: format!("{} label {} at {} touches no wire or pin, so it names nothing", l.kind, l.text, fmt(l.at)),+                fix: "A label connects only where its anchor sits on a wire or a pin end: move it there (delete with kicad_sch_delete and re-add with kicad_sch_label at the wire point), or delete it.".into(),+            });+        }+    }++    // unconnected pins (not passive, not power symbols, not hidden power pins), and flags on wired pins+    for (i, p) in m.pins.iter().enumerate() {+        if p.power_symbol || p.hidden {+            continue;+        }+        let flagged = m.no_connects.iter().any(|n| same(n.at, p.at));+        let attached = on_wire(m, p.at, false) || m.labels.iter().any(|l| same(l.at, p.at)) || m.sheet_pins().any(|s| same(s.at, p.at)) || m.pins.iter().enumerate().any(|(k, q)| k != i && same(q.at, p.at));+        if flagged && attached {+            f.push(Finding {+                check: "no_connect_connected",+                severity: "warning",+                at: pt(p.at),+                uuid: None,+                refs: vec![p.reference.clone()],+                nets: vec![],+                message: format!("{} is wired but also carries a no-connect flag", pin_label(p)),+                fix: "Delete the no-connect flag (kicad_sch_delete with its uuid from kicad_sch_trace_point at the pin) or the wire, whichever is wrong.".into(),+            });+            continue;+        }+        if flagged || attached || p.electrical == "passive" || p.electrical == "no_connect" {+            continue;+        }+        f.push(Finding {+            check: "unconnected_pin",+            severity: "error",+            at: pt(p.at),+            uuid: Some(p.symbol_uuid.clone()),+            refs: vec![p.reference.clone()],+            nets: vec![],+            message: format!("{} ({}) is not connected", pin_label(p), p.electrical),+            fix: if p.electrical == "input" || p.electrical == "power_in" {+                "An open input floats: wire it to its net (kicad_sch_wire to the pin point, at), tie it high or low, or put a no-connect flag on it if the datasheet allows (kicad_sch_no_connect {pin}).".into()+            } else {+                "Wire it (kicad_sch_wire) or flag it intentionally open (kicad_sch_no_connect {pin:\"Ref.Pin\"}).".into()+            },+        });+    }++    // single-pin nets and distinct names shorted together+    let mut cross = 0;+    for (gi, g) in c.groups.iter().enumerate() {+        let part_pins: Vec<_> = g.part_pins(m).collect();+        let has_wire_or_label = g.has(|i| matches!(i, Item::Wire(_) | Item::Label(_)));+        let flagged = g.has(|i| matches!(i, Item::NoConnect(_)));+        if part_pins.len() == 1 && has_wire_or_label && !flagged {+            if g.leaves_sheet() {+                cross += 1;+            } else {+                let p = part_pins[0];+                f.push(Finding {+                    check: "single_pin_net",+                    severity: "warning",+                    at: pt(p.at),+                    uuid: None,+                    refs: vec![p.reference.clone()],+                    nets: vec![c.net_name(m, gi)],+                    message: format!("Net {} reaches only {}: a missing counterpart, an orphan label or a stub", c.net_name(m, gi), pin_label(p)),+                    fix: "Wire the other end, or if the pin is meant to be open replace the stub with a no-connect flag (kicad_sch_no_connect).".into(),+                });+            }+        }+        let names: Vec<&(String, String)> = g.names.iter().filter(|(k, _)| k != "sheet_pin").collect();+        let mut distinct: Vec<&str> = names.iter().map(|(_, t)| t.as_str()).collect();+        distinct.sort();+        distinct.dedup();+        if distinct.len() >= 2 {+            let rails: Vec<&str> = {+                let mut v: Vec<&str> = names.iter().filter(|(k, _)| k == "power" || k == "global").map(|(_, t)| t.as_str()).collect();+                v.sort();+                v.dedup();+                v+            };+            let hard = rails.len() >= 2;+            let at = g.items.iter().find_map(|i| if let Item::Label(k) = i { Some(m.labels[*k].at) } else { None }).or_else(|| part_pins.first().map(|p| p.at));+            f.push(Finding {+                check: "shorted_nets",+                severity: if hard { "error" } else { "warning" },+                at: at.and_then(pt),+                uuid: None,+                refs: part_pins.iter().map(|p| format!("{}.{}", p.reference, p.number)).collect(),+                nets: distinct.iter().map(|s| s.to_string()).collect(),+                message: format!(+                    "One net carries {} names: {}{}",+                    distinct.len(),+                    names.iter().map(|(k, t)| format!("{t} ({k})")).collect::<Vec<_>>().join(", "),+                    if hard { "; two power or global rails are shorted" } else { "; eeschema keeps one and warns (multiple_net_names)" }+                ),+                fix: if hard {+                    "Two rails meet on one wire: find the wire joining them (kicad_sch_trace_point at the location) and break it, unless they really are one rail, then keep one name.".into()+                } else {+                    "If they are meant to be one net keep one name (a hierarchical label beside a power symbol is common and harmless); otherwise separate the wires.".into()+                },+            });+        }+    }++    if opts.overlaps {+        overlaps(m, &mut f);+    }+    CheckReport { findings: f, cross_sheet_skipped: cross }+}++fn bb_json(b: BBox) -> Value {+    json!([r4(b.0), r4(b.1), r4(b.2), r4(b.3)])+}++fn overlaps(m: &SheetModel, f: &mut Vec<Finding>) {+    let syms: Vec<(&str, BBox, bool)> = m.symbols.iter().filter_map(|s| s.bbox.map(|b| (s.reference.as_str(), b, s.is_power))).collect();+    for i in 0..syms.len() {+        for j in (i + 1)..syms.len() {+            let (a, b) = (&syms[i], &syms[j]);+            if a.0 == b.0 {+                continue; // units of one part+            }+            if let Some(o) = overlap(a.1, b.1, 0.25) {+                f.push(Finding {+                    check: "overlap",+                    severity: if a.2 || b.2 { "info" } else { "warning" },+                    at: Some([r4((o.0 + o.2) / 2.0), r4((o.1 + o.3) / 2.0)]),+                    uuid: None,+                    refs: vec![a.0.to_string(), b.0.to_string()],+                    nets: vec![],+                    message: format!("{} and {} overlap (bodies and pins share {} x {} mm)", a.0, b.0, crate::schematic::fmt_num(r4(o.2 - o.0)), crate::schematic::fmt_num(r4(o.3 - o.1))),+                    fix: format!("Move one of them clear: kicad_sch_move {{reference:\"{}\", to:[x, y]}}; bodies span {} and {}.", b.0, bb_json(a.1), bb_json(b.1)),+                });+            }+        }+    }+    // labels and free text over symbol bodies+    let mut texts: Vec<(String, BBox, Option<String>, &'static str, String)> = Vec::new();+    for l in &m.labels {+        let pad = if l.kind == "local" { 0.0 } else { l.size * 1.5 };+        let bb = crate::sch_model::text_bbox(l.at, &l.text, l.size, l.rotation, false, pad);+        texts.push((format!("{} label {}", l.kind, l.text), bb, None, "label", l.uuid.clone()));+    }+    for t in &m.texts {+        let what = match &t.owner {+            Some(o) => format!("field {:?} of {o}", t.text),+            None => format!("text {:?}", t.text),+        };+        texts.push((what, t.bbox, t.owner.clone(), if t.owner.is_some() { "field" } else { "text" }, t.uuid.clone()));+    }+    for (what, bb, owner, kind, uuid) in &texts {+        for (r, sb, is_power) in &syms {+            if owner.as_deref() == Some(*r) || *is_power {+                continue;+            }+            if let Some(o) = overlap(*bb, *sb, 0.15) {+                f.push(Finding {+                    check: "overlap",+                    severity: if *kind == "field" { "info" } else { "warning" },+                    at: Some([r4((o.0 + o.2) / 2.0), r4((o.1 + o.3) / 2.0)]),+                    uuid: Some(uuid.clone()),+                    refs: vec![r.to_string()],+                    nets: vec![],+                    message: format!("{what} runs over the body of {r}"),+                    fix: "Move the text clear of the symbol, or move the symbol (kicad_sch_move).".into(),+                });+            }+        }+    }+    for i in 0..texts.len() {+        for j in (i + 1)..texts.len() {+            let (a, b) = (&texts[i], &texts[j]);+            if a.2.is_some() && a.2 == b.2 {+                continue;+            }+            if let Some(o) = overlap(a.1, b.1, 0.15) {+                f.push(Finding {+                    check: "overlap",+                    severity: "info",+                    at: Some([r4((o.0 + o.2) / 2.0), r4((o.1 + o.3) / 2.0)]),+                    uuid: None,+                    refs: [a.2.clone(), b.2.clone()].into_iter().flatten().collect(),+                    nets: vec![],+                    message: format!("{} and {} overlap", a.0, b.0),+                    fix: "Move one of the two texts so both read.".into(),+                });+            }+        }+    }+}++// ---------------------------------------------------------------------------+// Trace from a point+// ---------------------------------------------------------------------------++/// Everything electrically connected at `at`: the items under the point and, for each net+/// they belong to, the whole net.+pub fn trace_point(m: &SheetModel, c: &Connectivity, at: Pt, tol: f64) -> Value {+    let near = |p: Pt| dist(p, at) <= tol;+    let mut here: Vec<Item> = Vec::new();+    for (i, w) in m.wires.iter().enumerate() {+        let seg_hit = {+            // distance from the point to the segment+            let (dx, dy) = (w.b.0 - w.a.0, w.b.1 - w.a.1);+            let l2 = dx * dx + dy * dy;+            let t = if l2 == 0.0 { 0.0 } else { (((at.0 - w.a.0) * dx + (at.1 - w.a.1) * dy) / l2).clamp(0.0, 1.0) };+            dist(at, (w.a.0 + t * dx, w.a.1 + t * dy)) <= tol+        };+        if seg_hit {+            here.push(Item::Wire(i));+        }+    }+    here.extend(m.pins.iter().enumerate().filter(|(_, p)| near(p.at)).map(|(i, _)| Item::Pin(i)));+    here.extend(m.labels.iter().enumerate().filter(|(_, p)| near(p.at)).map(|(i, _)| Item::Label(i)));+    let sps: Vec<_> = m.sheet_pins().collect();+    here.extend(sps.iter().enumerate().filter(|(_, p)| near(p.at)).map(|(i, _)| Item::SheetPin(i)));+    here.extend(m.junctions.iter().enumerate().filter(|(_, p)| near(p.at)).map(|(i, _)| Item::Junction(i)));+    here.extend(m.no_connects.iter().enumerate().filter(|(_, p)| near(p.at)).map(|(i, _)| Item::NoConnect(i)));++    let item_json = |it: &Item| -> Value {+        match *it {+            Item::Wire(i) => json!({"type": "wire", "uuid": m.wires[i].uuid, "from": [m.wires[i].a.0, m.wires[i].a.1], "to": [m.wires[i].b.0, m.wires[i].b.1]}),+            Item::Pin(i) => {+                let p = &m.pins[i];+                if p.power_symbol {+                    json!({"type": "power_symbol", "reference": p.reference, "net": p.power_net, "at": [p.at.0, p.at.1], "uuid": p.symbol_uuid})+                } else {+                    json!({"type": "pin", "pin": format!("{}.{}", p.reference, p.number), "name": p.name, "electrical": p.electrical, "at": [p.at.0, p.at.1], "symbolUuid": p.symbol_uuid})+                }+            }+            Item::Label(i) => json!({"type": "label", "kind": m.labels[i].kind, "text": m.labels[i].text, "at": [m.labels[i].at.0, m.labels[i].at.1], "uuid": m.labels[i].uuid}),+            Item::SheetPin(i) => json!({"type": "sheet_pin", "name": sps[i].name, "sheet": sps[i].sheet_name, "shape": sps[i].shape, "at": [sps[i].at.0, sps[i].at.1], "uuid": sps[i].uuid}),+            Item::Junction(i) => json!({"type": "junction", "at": [m.junctions[i].at.0, m.junctions[i].at.1], "uuid": m.junctions[i].uuid}),+            Item::NoConnect(i) => json!({"type": "no_connect", "at": [m.no_connects[i].at.0, m.no_connects[i].at.1], "uuid": m.no_connects[i].uuid}),+        }+    };+    let mut groups: Vec<usize> = here.iter().filter_map(|i| c.of.get(i).copied()).collect();+    groups.sort();+    groups.dedup();+    let nets: Vec<Value> = groups+        .iter()+        .map(|&g| {+            let grp = &c.groups[g];+            let pins: Vec<Value> = grp.part_pins(m).map(|p| json!({"pin": format!("{}.{}", p.reference, p.number), "name": p.name, "electrical": p.electrical, "at": [p.at.0, p.at.1]})).collect();+            let labels: Vec<Value> = grp.items.iter().filter_map(|i| if let Item::Label(k) = i { Some(json!({"kind": m.labels[*k].kind, "text": m.labels[*k].text, "at": [m.labels[*k].at.0, m.labels[*k].at.1], "uuid": m.labels[*k].uuid})) } else { None }).collect();+            let power: Vec<String> = grp.pins(m).filter_map(|p| p.power_net.clone()).collect::<std::collections::BTreeSet<_>>().into_iter().collect();+            let wires: Vec<String> = grp.items.iter().filter_map(|i| if let Item::Wire(k) = i { Some(m.wires[*k].uuid.clone()) } else { None }).collect::<std::collections::BTreeSet<_>>().into_iter().collect();+            let sheet_pins: Vec<Value> = grp.items.iter().filter_map(|i| if let Item::SheetPin(k) = i { Some(json!({"name": sps[*k].name, "sheet": sps[*k].sheet_name})) } else { None }).collect();+            let junctions = grp.items.iter().filter(|i| matches!(i, Item::Junction(_))).count();+            let ncs = grp.items.iter().filter(|i| matches!(i, Item::NoConnect(_))).count();+            json!({+                "net": c.net_name(m, g),+                "named": grp.best_name().is_some(),+                "leavesSheet": grp.leaves_sheet(),+                "pins": pins,+                "labels": labels,+                "powerSymbols": power,+                "sheetPins": sheet_pins,+                "wires": wires,+                "junctionCount": junctions,+                "noConnectCount": ncs,+            })+        })+        .collect();+    let crossing = {+        let wires_here: Vec<usize> = here.iter().filter_map(|i| if let Item::Wire(k) = i { Some(*k) } else { None }).collect();+        let gs: std::collections::BTreeSet<usize> = wires_here.iter().filter_map(|k| c.of.get(&Item::Wire(*k)).copied()).collect();+        gs.len() >= 2+    };+    json!({+        "at": [at.0, at.1],+        "tolerance": tol,+        "itemsHere": here.iter().map(item_json).collect::<Vec<_>>(),+        "netCount": nets.len(),+        "nets": nets,+        "crossingWithoutJunction": crossing,+    })+}++// ---------------------------------------------------------------------------+// Near-miss fixer+// ---------------------------------------------------------------------------++#[derive(Clone, Debug, Serialize)]+pub struct Move {+    #[serde(rename = "wireUuid")]+    pub wire_uuid: String,+    pub from: [f64; 2],+    pub to: [f64; 2],+    pub distance: f64,+    pub target: String,+    /// The snapped wire is no longer horizontal or vertical.+    pub diagonal: bool,+}++/// Dangling wire ends within `tol` of a pin, label, sheet pin or another wire's end.+pub fn plan_fixes(m: &SheetModel, tol: f64) -> (Vec<Move>, Vec<Value>) {+    let mut moves = Vec::new();+    let mut left = Vec::new();+    for (seg, p, other) in dangling_ends(m) {+        let w = &m.wires[seg];+        match nearest_target(m, p, tol, &w.uuid) {+            Some((q, what)) => {+                let diagonal = (q.0 - other.0).abs() > 1e-6 && (q.1 - other.1).abs() > 1e-6;+                moves.push(Move { wire_uuid: w.uuid.clone(), from: [r4(p.0), r4(p.1)], to: [r4(q.0), r4(q.1)], distance: r4(dist(p, q)), target: what, diagonal });+            }+            None => left.push(json!({"wireUuid": w.uuid, "at": [r4(p.0), r4(p.1)], "why": format!("nothing connectable within {tol} mm")})),+        }+    }+    (moves, left)+}++/// Rewrite the matching `(xy ..)` of each wire. Returns how many points moved.+pub fn apply_moves(sch: &mut Schematic, moves: &[Move]) -> usize {+    let mut n = 0;+    for mv in moves {+        let Some(idx) = sch.find_uuid_index(&mv.wire_uuid) else { continue };+        let node = &mut sch.doc.root.children_mut().expect("list")[idx];+        let Some(pts) = node.child_mut("pts") else { continue };+        for xy in pts.children_mut().expect("list").iter_mut().filter(|c| c.head() == Some("xy")) {+            let (Some(x), Some(y)) = (xy.num(0), xy.num(1)) else { continue };+            if same((x, y), (mv.from[0], mv.from[1])) {+                xy.set_arg(0, Node::number(mv.to[0]));+                xy.set_arg(1, Node::number(mv.to[1]));+                n += 1;+                break;+            }+        }+    }+    n+}