← 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 ·baad156f9d ·14d ago ·parent 0009b41
27 files changed +14909−12
rust/crates/kicad-core/src/sch_edit.rs+1711rust/crates/kicad-core/src/sch_hier.rs+1025rust/crates/kicad-core/src/sch_model.rs+819rust/crates/kicad-core/src/sch_render.rs+505rust/crates/kicad-core/src/sch_review.rs+688rust/crates/kicad-core/src/sch_sheets.rs+685rust/crates/kicad-core/src/schematic.rs+23−12rust/crates/kicad-core/src/silk_fields.rs+30rust/crates/kicad-core/src/svg_import.rs+600rust/crates/kicad-core/src/sxedit.rs+276rust/crates/kicad-core/src/symedit.rs+405rust/crates/kicad-core/tests/board_edit_fixture.rs+527rust/crates/kicad-core/tests/fab_mini.rs+47rust/crates/kicad-core/tests/fixtures/fab-mini.kicad_pcb+60rust/crates/kicad-core/tests/fixtures/konnect-route/Fixture.pretty/PinHeader_1x04_P2.00mm_Vertical.kicad_mod+69rust/crates/kicad-core/tests/fixtures/konnect-route/Fixture.pretty/R_0603_1608Metric.kicad_mod+53rust/crates/kicad-core/tests/fixtures/konnect-route/fp-lib-table+4rust/crates/kicad-core/tests/fixtures/konnect-route/route-fixture.kicad_dru+7rust/crates/kicad-core/tests/fixtures/konnect-route/route-fixture.kicad_pcb+770rust/crates/kicad-core/tests/fixtures/konnect-route/route-fixture.kicad_pro+362rust/crates/kicad-core/tests/fixtures/konnect-route/route-fixture.net+88rust/crates/kicad-core/tests/fixtures/sch-edit/MiniLib.kicad_sym+982rust/crates/kicad-core/tests/fixtures/sch-edit/edit10.kicad_sch+1659rust/crates/kicad-core/tests/fixtures/sch_hier/checks.kicad_sch+1712rust/crates/kicad-core/tests/fixtures/sch_hier/filter.kicad_sch+844rust/crates/kicad-core/tests/fixtures/sch_hier/hier.kicad_pro+13rust/crates/kicad-core/tests/fixtures/sch_hier/hier.kicad_sch+945
rust/crates/kicad-core/src/sch_edit.rsadded+1711
@@ -0,0 +1,1711 @@+//! Schematic edit, second half: the geometry and connectivity the wiring verbs need, on top+//! of the lossless document model in `schematic`.+//!+//! * symbol transforms: a library point or vector through a placed unit's angle and mirror+//!   (the same order `Schematic::pin_points` uses, checked against KiCad's demo sheets);+//! * every pin of every placed unit with its sheet point, outward direction, name, number+//!   and electrical type;+//! * wires, buses, labels, junctions, no-connects, bus entries, texts and sheets as plain+//!   lists;+//! * KiCad's junction rule (three or more distinct exits at a point, a wire end on another+//!   wire's interior counting as two), used to add dots after new wiring and to prune them+//!   after a delete;+//! * the edit operations behind the newer kicad_sch_* verbs: multi-unit move with wire+//!   stretch, rotate, region move, annotate, symbol replace and library refresh, field reset,+//!   wire delete and split, label edit, junctions, stubs to a net, pin to pin routes, text,+//!   buses and bus entries.+//!+//! Everything here is pure document work; the verb layer resolves libraries, runs ERC and+//! writes the file.++use std::collections::{BTreeMap, BTreeSet};+use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::schematic::{+    fmt_num, font_node, is_symbol_instance, lib_symbol_pins, new_uuid, norm_rotation, pretty, project_name_for, property_node, quote, sub_unit, symbol_property, xy, Kind, Node, Schematic,+};++pub type Pt = (f64, f64);++/// Coordinates closer than this are the same point (KiCad stores nanometres; files carry+/// at most four decimals of a millimetre).+pub const EPS: f64 = 1e-3;++/// The schematic grid KiCad's connection points sit on.+pub const GRID: f64 = 1.27;++/// An operation error with a stable code for the verb layer.+#[derive(Clone, Debug)]+pub struct OpError {+    pub code: &'static str,+    pub msg: String,+}++impl OpError {+    pub fn new(code: &'static str, msg: impl Into<String>) -> OpError {+        OpError { code, msg: msg.into() }+    }+}++impl From<String> for OpError {+    fn from(msg: String) -> OpError {+        OpError { code: "edit_failed", msg }+    }+}++impl std::fmt::Display for OpError {+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {+        write!(f, "{}: {}", self.code, self.msg)+    }+}++pub type R<T> = Result<T, OpError>;++pub fn same(a: Pt, b: Pt) -> bool {+    (a.0 - b.0).abs() < EPS && (a.1 - b.1).abs() < EPS+}++pub fn r4(v: f64) -> f64 {+    (v * 1e4).round() / 1e4 + 0.0+}++pub fn rp(p: Pt) -> Pt {+    (r4(p.0), r4(p.1))+}++/// Round to the nearest 1.27 mm grid point.+pub fn snap(p: Pt) -> Pt {+    (r4((p.0 / GRID).round() * GRID), r4((p.1 / GRID).round() * GRID))+}++/// `p` lies on the segment a-b (ends included).+pub fn on_segment(p: Pt, a: Pt, b: Pt) -> bool {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let len = (dx * dx + dy * dy).sqrt();+    if len < EPS {+        return same(p, a);+    }+    let cross = ((p.0 - a.0) * dy - (p.1 - a.1) * dx) / len;+    if cross.abs() > EPS {+        return false;+    }+    let t = ((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len;+    t > -EPS && t < len + EPS+}++/// `p` lies on a-b but is neither end.+pub fn on_interior(p: Pt, a: Pt, b: Pt) -> bool {+    !same(p, a) && !same(p, b) && on_segment(p, a, b)+}++pub fn in_box(p: Pt, bx: [f64; 4]) -> bool {+    let (x0, x1) = (bx[0].min(bx[2]), bx[0].max(bx[2]));+    let (y0, y1) = (bx[1].min(bx[3]), bx[1].max(bx[3]));+    p.0 > x0 - EPS && p.0 < x1 + EPS && p.1 > y0 - EPS && p.1 < y1 + EPS+}++fn unit_dir(from: Pt, to: Pt) -> Pt {+    let (dx, dy) = (to.0 - from.0, to.1 - from.1);+    let l = (dx * dx + dy * dy).sqrt();+    if l < EPS {+        (0.0, 0.0)+    } else {+        (r4(dx / l), r4(dy / l))+    }+}++/// "right", "left", "up", "down" (sheet view, y down) for an axis vector, else the angle.+pub fn dir_name(v: Pt) -> String {+    if same(v, (1.0, 0.0)) {+        "right".into()+    } else if same(v, (-1.0, 0.0)) {+        "left".into()+    } else if same(v, (0.0, -1.0)) {+        "up".into()+    } else if same(v, (0.0, 1.0)) {+        "down".into()+    } else {+        format!("{}deg", fmt_num((-v.1).atan2(v.0).to_degrees()))+    }+}++pub fn pt_json(p: Pt) -> Value {+    json!([r4(p.0), r4(p.1)])+}++fn at_of(n: &Node) -> Option<Pt> {+    let a = n.child("at")?;+    Some((a.num(0)?, a.num(1)?))+}++fn set_at_xy(n: &mut Node, p: Pt) {+    if let Some(a) = n.child_mut("at") {+        a.set_arg(0, Node::number(r4(p.0)));+        a.set_arg(1, Node::number(r4(p.1)));+    }+}++/// Shift every `(at x y ..)`, `(xy x y)`, `(start x y)`, `(end x y)` and `(center x y)` in a+/// top-level item by (dx, dy): a symbol instance's anchor and fields, a label and its+/// properties, a sheet and its pins, a text box.+pub fn shift_node(n: &mut Node, dx: f64, dy: f64) {+    let Some(children) = n.children_mut() else { return };+    for c in children.iter_mut() {+        match c.head() {+            Some("at") | Some("xy") | Some("start") | Some("end") | Some("center") | Some("mid") => {+                if let (Some(x), Some(y)) = (c.num(0), c.num(1)) {+                    c.set_arg(0, Node::number(r4(x + dx)));+                    c.set_arg(1, Node::number(r4(y + dy)));+                }+            }+            // An instance's `instances` block and stroke/effects carry no coordinates.+            Some("effects") | Some("stroke") | Some("instances") | Some("size") => {}+            _ => {+                if c.is_list() {+                    shift_node(c, dx, dy);+                }+            }+        }+    }+}++/// A canonical, whitespace-free spelling of a node, to compare two library blocks.+pub fn canon(n: &Node) -> String {+    match &n.kind {+        Kind::Atom(s) => s.clone(),+        Kind::List { children, .. } => format!("({})", children.iter().map(canon).collect::<Vec<_>>().join(" ")),+    }+}++// ---------------------------------------------------------------------------+// Symbol transform and pins+// ---------------------------------------------------------------------------++/// A placed unit's placement: anchor, angle (counterclockwise as seen) and mirror.+#[derive(Clone, Copy, Debug)]+pub struct SymXf {+    pub x: f64,+    pub y: f64,+    pub rot: f64,+    pub mirror: Option<char>,+}++impl SymXf {+    pub fn of(inst: &Node) -> SymXf {+        let at = inst.child("at");+        SymXf {+            x: at.and_then(|a| a.num(0)).unwrap_or(0.0),+            y: at.and_then(|a| a.num(1)).unwrap_or(0.0),+            rot: at.and_then(|a| a.num(2)).unwrap_or(0.0),+            mirror: inst.child_value("mirror").and_then(|m| m.chars().next()),+        }+    }+    /// A library vector (y up) as a sheet vector (y down).+    pub fn vec(&self, v: Pt) -> Pt {+        let (s, c) = self.rot.to_radians().sin_cos();+        let (mut qx, mut qy) = (v.0 * c - v.1 * s, v.0 * s + v.1 * c);+        match self.mirror {+            Some('x') => qy = -qy,+            Some('y') => qx = -qx,+            _ => {}+        }+        (r4(qx), r4(-qy))+    }+    /// A library point as a sheet point.+    pub fn point(&self, p: Pt) -> Pt {+        let v = self.vec(p);+        rp((self.x + v.0, self.y + v.1))+    }+}++/// Rotate a sheet vector (y down) counterclockwise as seen by `deg`.+pub fn rotate_sheet_vec(v: Pt, deg: f64) -> Pt {+    let (s, c) = deg.to_radians().sin_cos();+    let (ux, uy) = (v.0, -v.1);+    let (rx, ry) = (ux * c - uy * s, ux * s + uy * c);+    (r4(rx), r4(-ry))+}++/// One pin of a library symbol.+#[derive(Clone, Debug)]+pub struct LibPin {+    pub number: String,+    pub name: String,+    pub etype: String,+    pub at: Pt,+    pub angle: f64,+    pub length: f64,+    pub unit: u32,+    pub hidden: bool,+}++/// The pins a placed unit carries: shared (unit 0) and its own, body style `style` (or the+/// shared style 0). Library coordinates.+pub fn lib_unit_pins(lib: &Node, base: &str, unit: u32, style: u32) -> Vec<LibPin> {+    let mut out = Vec::new();+    for sub in lib.children().iter().filter(|n| n.head() == Some("symbol")) {+        let Some(name) = sub.arg(0) else { continue };+        let Some(u) = sub_unit(&name, base) else { continue };+        let st: u32 = name.rsplit('_').next().and_then(|s| s.parse().ok()).unwrap_or(1);+        if (u != 0 && u != unit) || (st != 0 && st != style) {+            continue;+        }+        for pin in sub.children().iter().filter(|n| n.head() == Some("pin")) {+            let (Some(num), Some(pat)) = (pin.child_value("number"), pin.child("at")) else { continue };+            let hidden = pin.children().iter().any(|c| c.raw() == Some("hide")) || pin.child("hide").and_then(|h| h.arg(0)).as_deref() == Some("yes");+            out.push(LibPin {+                number: num,+                name: pin.child_value("name").unwrap_or_default(),+                etype: pin.arg(0).unwrap_or_default(),+                at: (pat.num(0).unwrap_or(0.0), pat.num(1).unwrap_or(0.0)),+                angle: pat.num(2).unwrap_or(0.0),+                length: pin.child("length").and_then(|l| l.num(0)).unwrap_or(0.0),+                unit: u,+                hidden,+            });+        }+    }+    out+}++/// Number of units a library symbol defines (1 when it has only shared sub-symbols).+pub fn lib_unit_count(lib: &Node, base: &str) -> u32 {+    lib.children()+        .iter()+        .filter(|n| n.head() == Some("symbol"))+        .filter_map(|n| n.arg(0).and_then(|name| sub_unit(&name, base)))+        .max()+        .unwrap_or(1)+        .max(1)+}++/// A pin of a placed unit, in sheet coordinates.+#[derive(Clone, Debug)]+pub struct PinInfo {+    pub reference: String,+    pub unit: u32,+    pub number: String,+    pub name: String,+    pub etype: String,+    pub at: Pt,+    /// Unit vector from the pin's connection point away from the body (sheet, y down).+    pub out: Pt,+    pub length: f64,+    pub symbol_uuid: String,+    pub hidden: bool,+    pub lib_id: String,+}++impl PinInfo {+    pub fn spec(&self) -> String {+        format!("{}.{}", self.reference, self.number)+    }+}++/// The key of an instance's `lib_symbols` entry (lib_name wins over lib_id).+pub fn lib_key(inst: &Node) -> String {+    inst.child_value("lib_name").or_else(|| inst.child_value("lib_id")).unwrap_or_default()+}++pub fn base_name(key: &str) -> String {+    key.rsplit(':').next().unwrap_or(key).to_string()+}++fn inst_unit(inst: &Node) -> u32 {+    inst.child("unit").and_then(|u| u.num(0)).unwrap_or(1.0) as u32+}++fn inst_style(inst: &Node) -> u32 {+    inst.child("body_style").or_else(|| inst.child("convert")).and_then(|c| c.num(0)).map(|v| v as u32).unwrap_or(1)+}++/// A wire or bus segment.+#[derive(Clone, Debug)]+pub struct Seg {+    pub uuid: String,+    pub a: Pt,+    pub b: Pt,+}++fn seg_of(n: &Node) -> Option<Seg> {+    let pts = n.child("pts")?;+    let xys: Vec<&Node> = pts.children().iter().filter(|c| c.head() == Some("xy")).collect();+    if xys.len() < 2 {+        return None;+    }+    Some(Seg { uuid: n.child_value("uuid").unwrap_or_default(), a: (xys[0].num(0)?, xys[0].num(1)?), b: (xys[1].num(0)?, xys[1].num(1)?) })+}++fn set_seg_end(n: &mut Node, end: usize, p: Pt) {+    let Some(pts) = n.child_mut("pts") else { return };+    let Some(children) = pts.children_mut() else { return };+    if let Some(x) = children.iter_mut().filter(|c| c.head() == Some("xy")).nth(end) {+        x.set_arg(0, Node::number(r4(p.0)));+        x.set_arg(1, Node::number(r4(p.1)));+    }+}++pub const LABEL_HEADS: &[&str] = &["label", "global_label", "hierarchical_label"];++/// A net label of any kind.+#[derive(Clone, Debug)]+pub struct LabelInfo {+    pub kind: String,+    pub text: String,+    pub at: Pt,+    pub rotation: f64,+    pub uuid: String,+    pub shape: Option<String>,+}++/// What touches one point: for the junction rule and the pin reports.+struct Conn {+    wires: Vec<Seg>,+    pin_pts: Vec<Pt>,+}++impl Conn {+    /// KiCad's explicit-junction rule: three or more distinct exits meet at `p` (a wire end+    /// gives one exit, a wire passing through gives two, every pin its own), and a wire is+    /// one of them.+    fn needed(&self, p: Pt) -> bool {+        let mut dirs: Vec<Pt> = Vec::new();+        let mut uniques = 0usize;+        let mut wire = false;+        let push = |d: Pt, dirs: &mut Vec<Pt>| {+            if !dirs.iter().any(|e| same(*e, d)) {+                dirs.push(d);+            }+        };+        for s in &self.wires {+            if same(p, s.a) {+                wire = true;+                push(unit_dir(s.a, s.b), &mut dirs);+            } else if same(p, s.b) {+                wire = true;+                push(unit_dir(s.b, s.a), &mut dirs);+            } else if on_interior(p, s.a, s.b) {+                wire = true;+                push(unit_dir(p, s.a), &mut dirs);+                push(unit_dir(p, s.b), &mut dirs);+            }+        }+        for q in &self.pin_pts {+            if same(*q, p) {+                uniques += 1;+            }+        }+        wire && dirs.len() + uniques >= 3+    }+}++// ---------------------------------------------------------------------------+// Schematic: queries+// ---------------------------------------------------------------------------++impl Schematic {+    /// Top-level indices of every placed unit of `reference`.+    pub fn unit_indices(&self, reference: &str) -> Vec<usize> {+        self.top().iter().enumerate().filter(|(_, n)| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(reference)).map(|(i, _)| i).collect()+    }++    fn require_units(&self, reference: &str) -> R<Vec<usize>> {+        let v = self.unit_indices(reference);+        if v.is_empty() {+            return Err(OpError::new("not_found", format!("no symbol with reference {reference} on this sheet")));+        }+        Ok(v)+    }++    /// The pins of one placed unit (top-level node).+    pub fn instance_pins(&self, inst: &Node) -> R<Vec<PinInfo>> {+        let key = lib_key(inst);+        let reference = symbol_property(inst, "Reference").unwrap_or_default();+        let lib = self.lib_symbol(&key).ok_or_else(|| OpError::new("lib_symbol_missing", format!("{reference}: its library symbol {key} is not in the sheet's lib_symbols")))?;+        let xf = SymXf::of(inst);+        let unit = inst_unit(inst);+        let uuid = inst.child_value("uuid").unwrap_or_default();+        let lib_id = inst.child_value("lib_id").unwrap_or_default();+        Ok(lib_unit_pins(lib, &base_name(&key), unit, inst_style(inst))+            .into_iter()+            .map(|p| {+                let a = p.angle.to_radians();+                let out = xf.vec((-a.cos(), -a.sin()));+                PinInfo {+                    reference: reference.clone(),+                    unit,+                    number: p.number,+                    name: p.name,+                    etype: p.etype,+                    at: xf.point(p.at),+                    out,+                    length: p.length,+                    symbol_uuid: uuid.clone(),+                    hidden: p.hidden,+                    lib_id: lib_id.clone(),+                }+            })+            .collect())+    }++    /// Every pin of every placed unit of `reference`.+    pub fn pins_of(&self, reference: &str) -> R<Vec<PinInfo>> {+        let mut out = Vec::new();+        for i in self.require_units(reference)? {+            out.extend(self.instance_pins(&self.top()[i])?);+        }+        Ok(out)+    }++    /// Every pin on the sheet (units whose library symbol is missing are skipped).+    pub fn all_pins(&self) -> Vec<PinInfo> {+        self.top().iter().filter(|n| is_symbol_instance(n)).filter_map(|n| self.instance_pins(n).ok()).flatten().collect()+    }++    /// `U1.3` or `U1.PA10`: by number first, then by name (case-insensitive).+    pub fn find_pin(&self, spec: &str) -> R<PinInfo> {+        let (reference, want) = spec.trim().split_once('.').ok_or_else(|| OpError::new("bad_arg", format!("pin {spec} is not Ref.Pin (e.g. U1.12 or U1.PA10)")))?;+        let pins = self.pins_of(reference)?;+        if let Some(p) = pins.iter().find(|p| p.number == want) {+            return Ok(p.clone());+        }+        if let Some(p) = pins.iter().find(|p| p.name.eq_ignore_ascii_case(want)) {+            return Ok(p.clone());+        }+        Err(OpError::new("pin_not_found", format!("{reference} has no pin {want} (numbers: {})", pins.iter().map(|p| p.number.clone()).collect::<Vec<_>>().join(", "))))+    }++    pub fn segments(&self, head: &str) -> Vec<Seg> {+        self.top().iter().filter(|n| n.head() == Some(head)).filter_map(seg_of).collect()+    }++    pub fn wires(&self) -> Vec<Seg> {+        self.segments("wire")+    }++    pub fn labels(&self) -> Vec<LabelInfo> {+        self.top()+            .iter()+            .filter(|n| n.head().map_or(false, |h| LABEL_HEADS.contains(&h)))+            .map(|n| {+                let at = n.child("at");+                LabelInfo {+                    kind: n.head().unwrap_or("").to_string(),+                    text: n.arg(0).unwrap_or_default(),+                    at: (at.and_then(|a| a.num(0)).unwrap_or(0.0), at.and_then(|a| a.num(1)).unwrap_or(0.0)),+                    rotation: at.and_then(|a| a.num(2)).unwrap_or(0.0),+                    uuid: n.child_value("uuid").unwrap_or_default(),+                    shape: n.child_value("shape"),+                }+            })+            .collect()+    }++    /// (uuid, point) of every top-level item with head `head` and an `(at x y)`.+    pub fn point_items(&self, head: &str) -> Vec<(String, Pt)> {+        self.top().iter().filter(|n| n.head() == Some(head)).filter_map(|n| Some((n.child_value("uuid").unwrap_or_default(), at_of(n)?))).collect()+    }++    pub fn junction_at(&self, p: Pt) -> Option<String> {+        self.point_items("junction").into_iter().find(|(_, q)| same(*q, p)).map(|(u, _)| u)+    }++    fn conn(&self) -> Conn {+        Conn { wires: self.wires(), pin_pts: self.all_pins().into_iter().map(|p| p.at).collect() }+    }++    /// True when KiCad would want a junction dot at `p` (ignoring any dot already there).+    pub fn junction_needed(&self, p: Pt) -> bool {+        self.conn().needed(p)+    }++    /// What touches a point: wires (end or interior), labels, no-connects, junctions, pins.+    pub fn touching(&self, p: Pt) -> Value {+        let wires: Vec<Value> = self+            .wires()+            .into_iter()+            .filter(|s| on_segment(p, s.a, s.b))+            .map(|s| json!({"uuid": s.uuid, "from": pt_json(s.a), "to": pt_json(s.b), "atEnd": same(p, s.a) || same(p, s.b)}))+            .collect();+        let labels: Vec<Value> = self.labels().into_iter().filter(|l| same(l.at, p)).map(|l| json!({"kind": l.kind, "text": l.text, "uuid": l.uuid})).collect();+        json!({+            "wires": wires,+            "labels": labels,+            "noConnect": self.point_items("no_connect").iter().any(|(_, q)| same(*q, p)),+            "junction": self.junction_at(p),+        })+    }++    /// The net names reachable from `p` through wires (shared ends, junctions on interiors,+    /// labels anywhere on a wire), and the other pins on that copper. A label or power pin+    /// sitting directly on `p` counts too.+    pub fn net_at(&self, p: Pt, pins: &[PinInfo]) -> (Vec<String>, Vec<String>) {+        let wires = self.wires();+        let junctions: Vec<Pt> = self.point_items("junction").into_iter().map(|(_, q)| q).collect();+        let labels = self.labels();+        let mut names: BTreeSet<String> = BTreeSet::new();+        let mut reached_pts: Vec<Pt> = vec![p];+        let mut used = vec![false; wires.len()];+        let mut frontier = vec![p];+        while let Some(q) = frontier.pop() {+            for (i, s) in wires.iter().enumerate() {+                if used[i] {+                    continue;+                }+                let at_end = same(q, s.a) || same(q, s.b);+                let mid = on_interior(q, s.a, s.b) && (junctions.iter().any(|j| same(*j, q)) || same(q, p));+                if !(at_end || mid) {+                    continue;+                }+                used[i] = true;+                for e in [s.a, s.b] {+                    if !reached_pts.iter().any(|r| same(*r, e)) {+                        reached_pts.push(e);+                        frontier.push(e);+                    }+                }+                for j in &junctions {+                    if on_interior(*j, s.a, s.b) && !reached_pts.iter().any(|r| same(*r, *j)) {+                        reached_pts.push(*j);+                        frontier.push(*j);+                    }+                }+                for l in &labels {+                    if on_segment(l.at, s.a, s.b) {+                        names.insert(l.text.clone());+                    }+                }+            }+        }+        for l in &labels {+            if same(l.at, p) {+                names.insert(l.text.clone());+            }+        }+        let mut others = Vec::new();+        for pin in pins {+            if reached_pts.iter().any(|r| same(*r, pin.at)) || wires.iter().enumerate().any(|(i, s)| used[i] && on_interior(pin.at, s.a, s.b)) {+                if pin.reference.starts_with('#') {+                    // power symbol: its Value is the net name+                    if let Some(inst) = self.top().iter().find(|n| n.child_value("uuid").as_deref() == Some(pin.symbol_uuid.as_str())) {+                        if inst.child_value("lib_id").map_or(false, |l| l.starts_with("power:")) || self.lib_symbol(&lib_key(inst)).map_or(false, |l| l.child("power").is_some()) {+                            if let Some(v) = symbol_property(inst, "Value") {+                                names.insert(v);+                            }+                        }+                    }+                }+                others.push(pin.spec());+            }+        }+        (names.into_iter().collect(), others)+    }++    /// Every item kind as plain lists (for kicad_sch_items).+    pub fn items(&self, kinds: Option<&[String]>) -> Value {+        let want = |k: &str| kinds.map_or(true, |ks| ks.iter().any(|x| x == k));+        let mut v = serde_json::Map::new();+        let segs = |head: &str| -> Vec<Value> { self.segments(head).into_iter().map(|s| json!({"uuid": s.uuid, "from": pt_json(s.a), "to": pt_json(s.b)})).collect() };+        if want("wires") {+            v.insert("wires".into(), json!(segs("wire")));+        }+        if want("buses") {+            v.insert("buses".into(), json!(segs("bus")));+        }+        if want("busEntries") {+            let e: Vec<Value> = self+                .top()+                .iter()+                .filter(|n| n.head() == Some("bus_entry"))+                .filter_map(|n| {+                    let at = at_of(n)?;+                    let sz = n.child("size").map(|s| (s.num(0).unwrap_or(0.0), s.num(1).unwrap_or(0.0))).unwrap_or((2.54, 2.54));+                    Some(json!({"uuid": n.child_value("uuid"), "from": pt_json(at), "to": pt_json((at.0 + sz.0, at.1 + sz.1)), "size": pt_json(sz)}))+                })+                .collect();+            v.insert("busEntries".into(), json!(e));+        }+        if want("labels") {+            let l: Vec<Value> = self.labels().into_iter().map(|l| json!({"kind": l.kind, "text": l.text, "at": pt_json(l.at), "rotation": l.rotation, "shape": l.shape, "uuid": l.uuid})).collect();+            v.insert("labels".into(), json!(l));+        }+        if want("powerSymbols") {+            let p: Vec<Value> = self+                .top()+                .iter()+                .filter(|n| is_symbol_instance(n) && (n.child_value("lib_id").map_or(false, |l| l.starts_with("power:")) || self.lib_symbol(&lib_key(n)).map_or(false, |l| l.child("power").is_some())))+                .map(|n| json!({"reference": symbol_property(n, "Reference"), "net": symbol_property(n, "Value"), "libId": n.child_value("lib_id"), "at": at_of(n).map(pt_json), "uuid": n.child_value("uuid")}))+                .collect();+            v.insert("powerSymbols".into(), json!(p));+        }+        let pts = |head: &str| -> Vec<Value> { self.point_items(head).into_iter().map(|(u, p)| json!({"uuid": u, "at": pt_json(p)})).collect() };+        if want("junctions") {+            v.insert("junctions".into(), json!(pts("junction")));+        }+        if want("noConnects") {+            v.insert("noConnects".into(), json!(pts("no_connect")));+        }+        if want("texts") {+            let t: Vec<Value> = self+                .top()+                .iter()+                .filter(|n| n.head() == Some("text"))+                .map(|n| json!({"text": n.arg(0), "at": at_of(n).map(pt_json), "rotation": n.child("at").and_then(|a| a.num(2)).unwrap_or(0.0), "uuid": n.child_value("uuid")}))+                .collect();+            v.insert("texts".into(), json!(t));+        }+        if want("sheets") {+            let s: Vec<Value> = self+                .top()+                .iter()+                .filter(|n| n.head() == Some("sheet"))+                .map(|n| {+                    let size = n.child("size").map(|s| json!([s.num(0), s.num(1)]));+                    let pins: Vec<Value> = n.children().iter().filter(|c| c.head() == Some("pin")).map(|c| json!({"name": c.arg(0), "shape": c.arg(1), "at": at_of(c).map(pt_json), "uuid": c.child_value("uuid")})).collect();+                    json!({"name": symbol_property(n, "Sheetname").or_else(|| symbol_property(n, "Sheet name")), "file": symbol_property(n, "Sheetfile").or_else(|| symbol_property(n, "Sheet file")), "at": at_of(n).map(pt_json), "size": size, "pins": pins, "uuid": n.child_value("uuid")})+                })+                .collect();+            v.insert("sheets".into(), json!(s));+        }+        Value::Object(v)+    }++    /// Placed symbols grouped by reference with every unit and every field.+    pub fn components(&self, filter: Option<&[String]>) -> Vec<Value> {+        let mut order: Vec<String> = Vec::new();+        let mut by_ref: BTreeMap<String, Vec<&Node>> = BTreeMap::new();+        for n in self.top().iter().filter(|n| is_symbol_instance(n)) {+            let r = symbol_property(n, "Reference").unwrap_or_default();+            if let Some(f) = filter {+                if !f.iter().any(|x| x == &r) {+                    continue;+                }+            }+            // unannotated X? parts are separate parts, not units of one+            let key = if r.trim().ends_with('?') { format!("{r}\u{0}{}", n.child_value("uuid").unwrap_or_default()) } else { r };+            if !by_ref.contains_key(&key) {+                order.push(key.clone());+            }+            by_ref.entry(key).or_default().push(n);+        }+        order+            .iter()+            .map(|r| {+                let units = &by_ref[r];+                let first = units[0];+                let fields: Vec<Value> = first+                    .children()+                    .iter()+                    .filter(|c| c.head() == Some("property"))+                    .map(|c| {+                        let hidden = c.child("effects").map_or(false, |e| e.child("hide").map_or(false, |h| h.arg(0).as_deref() != Some("no")) || e.children().iter().any(|x| x.raw() == Some("hide")));+                        json!({"name": c.arg(0), "value": c.arg(1), "at": at_of(c).map(pt_json), "angle": c.child("at").and_then(|a| a.num(2)).unwrap_or(0.0), "hidden": hidden})+                    })+                    .collect();+                let key = lib_key(first);+                let unit_count = self.lib_symbol(&key).map(|l| lib_unit_count(l, &base_name(&key)));+                let us: Vec<Value> = units+                    .iter()+                    .map(|u| {+                        let xf = SymXf::of(u);+                        json!({+                            "unit": inst_unit(u),+                            "uuid": u.child_value("uuid"),+                            "at": pt_json((xf.x, xf.y)),+                            "rotation": xf.rot,+                            "mirror": u.child_value("mirror"),+                            "inBom": u.child_value("in_bom").map(|v| v == "yes"),+                            "onBoard": u.child_value("on_board").map(|v| v == "yes"),+                            "dnp": u.child_value("dnp").map(|v| v == "yes"),+                        })+                    })+                    .collect();+                json!({+                    "reference": symbol_property(first, "Reference"),+                    "value": symbol_property(first, "Value"),+                    "footprint": symbol_property(first, "Footprint"),+                    "datasheet": symbol_property(first, "Datasheet"),+                    "libId": first.child_value("lib_id"),+                    "libName": first.child_value("lib_name"),+                    "unitCount": unit_count,+                    "units": us,+                    "fields": fields,+                })+            })+            .collect()+    }+}++// ---------------------------------------------------------------------------+// Schematic: wiring edits+// ---------------------------------------------------------------------------++fn stroke() -> Node {+    Node::list("stroke", vec![Node::list("width", vec![Node::number(0.0)]), Node::list("type", vec![Node::atom("default")])])+}++impl Schematic {+    fn index_of_uuid(&self, uuid: &str) -> Option<usize> {+        self.find_uuid_index(uuid)+    }++    /// One `(wire)` or `(bus)` per straight segment of `points`.+    pub fn add_segments(&mut self, head: &str, points: &[Pt]) -> R<Vec<String>> {+        if points.len() < 2 {+            return Err(OpError::new("bad_arg", format!("a {head} needs at least two points")));+        }+        let mut uuids = Vec::new();+        for s in points.windows(2) {+            if same(s[0], s[1]) {+                continue;+            }+            let uuid = new_uuid();+            self.insert_item(Node::list(head, vec![Node::list("pts", vec![xy(rp(s[0])), xy(rp(s[1]))]), stroke(), Node::list("uuid", vec![Node::quoted(&uuid)])]));+            uuids.push(uuid);+        }+        if uuids.is_empty() {+            return Err(OpError::new("bad_arg", "all points are the same; nothing to draw"));+        }+        Ok(uuids)+    }++    pub fn add_junction(&mut self, p: Pt) -> String {+        let uuid = new_uuid();+        self.insert_item(Node::list(+            "junction",+            vec![+                Node::list("at", vec![Node::number(r4(p.0)), Node::number(r4(p.1))]),+                Node::list("diameter", vec![Node::number(0.0)]),+                Node::list("color", vec![Node::atom("0"), Node::atom("0"), Node::atom("0"), Node::atom("0")]),+                Node::list("uuid", vec![Node::quoted(&uuid)]),+            ],+        ));+        uuid+    }++    /// Add a dot at every point in `candidates` that KiCad's rule wants and that has none.+    pub fn auto_junctions(&mut self, candidates: &[Pt]) -> Vec<(String, Pt)> {+        let conn = self.conn();+        let mut done: Vec<Pt> = Vec::new();+        let mut out = Vec::new();+        for &p in candidates {+            if done.iter().any(|d| same(*d, p)) {+                continue;+            }+            done.push(p);+            if self.junction_at(p).is_none() && conn.needed(p) {+                out.push((self.add_junction(p), rp(p)));+            }+        }+        out+    }++    /// The points worth testing after new wires were drawn: their ends, and every existing+    /// wire end or pin that lies on one of them.+    pub fn junction_candidates(&self, new_uuids: &[String]) -> Vec<Pt> {+        let wires = self.wires();+        let new: Vec<&Seg> = wires.iter().filter(|s| new_uuids.contains(&s.uuid)).collect();+        let mut pts: Vec<Pt> = Vec::new();+        for s in &new {+            pts.push(s.a);+            pts.push(s.b);+        }+        for w in wires.iter().filter(|s| !new_uuids.contains(&s.uuid)) {+            for e in [w.a, w.b] {+                if new.iter().any(|s| on_interior(e, s.a, s.b)) {+                    pts.push(e);+                }+            }+        }+        for p in self.all_pins() {+            if new.iter().any(|s| on_interior(p.at, s.a, s.b)) {+                pts.push(p.at);+            }+        }+        pts+    }++    /// Draw a polyline and add the junctions it needs.+    pub fn add_wire_auto(&mut self, points: &[Pt]) -> R<(Vec<String>, Vec<(String, Pt)>)> {+        let uuids = self.add_segments("wire", points)?;+        let cands = self.junction_candidates(&uuids);+        let j = self.auto_junctions(&cands);+        Ok((uuids, j))+    }++    /// Remove every junction at one of `points` (or on one of `segs`) that the rule no+    /// longer wants. Returns the removed uuids.+    pub fn prune_junctions(&mut self, points: &[Pt], segs: &[(Pt, Pt)]) -> Vec<String> {+        let conn = self.conn();+        let doomed: Vec<String> = self+            .point_items("junction")+            .into_iter()+            .filter(|(_, p)| points.iter().any(|q| same(*q, *p)) || segs.iter().any(|(a, b)| on_segment(*p, *a, *b)))+            .filter(|(_, p)| !conn.needed(*p))+            .map(|(u, _)| u)+            .collect();+        for u in &doomed {+            if let Some(i) = self.index_of_uuid(u) {+                self.top_mut().remove(i);+            }+        }+        doomed+    }++    /// Delete wires by uuid; returns their geometry and the junctions pruned.+    pub fn delete_wires(&mut self, uuids: &[String]) -> R<(Vec<Seg>, Vec<String>)> {+        let mut gone = Vec::new();+        for u in uuids {+            let i = self.index_of_uuid(u).ok_or_else(|| OpError::new("not_found", format!("no item with uuid {u} on this sheet")))?;+            let n = &self.top()[i];+            if n.head() != Some("wire") && n.head() != Some("bus") {+                return Err(OpError::new("wrong_kind", format!("{u} is a {}, not a wire", n.head().unwrap_or("item"))));+            }+            let s = seg_of(n).ok_or_else(|| OpError::new("parse_failed", format!("wire {u} has no (pts ..)")))?;+            self.top_mut().remove(i);+            gone.push(s);+        }+        let pts: Vec<Pt> = gone.iter().flat_map(|s| [s.a, s.b]).collect();+        let segs: Vec<(Pt, Pt)> = gone.iter().map(|s| (s.a, s.b)).collect();+        let pruned = self.prune_junctions(&pts, &segs);+        Ok((gone, pruned))+    }++    /// Wires whose two ends are `a` and `b` (either order).+    pub fn wires_between(&self, a: Pt, b: Pt) -> Vec<String> {+        self.wires().into_iter().filter(|s| (same(s.a, a) && same(s.b, b)) || (same(s.a, b) && same(s.b, a))).map(|s| s.uuid).collect()+    }++    /// Split the wire passing through `at` into two; optionally a junction at the cut.+    pub fn split_wire(&mut self, at: Pt, uuid: Option<&str>, junction: bool) -> R<(String, String, Option<String>)> {+        let wires = self.wires();+        let hit: Vec<&Seg> = wires.iter().filter(|s| uuid.map_or(true, |u| s.uuid == u) && on_interior(at, s.a, s.b)).collect();+        let s = match hit.len() {+            0 => return Err(OpError::new("not_on_wire", format!("no wire{} passes through ({}, {}) away from its ends", uuid.map(|u| format!(" {u}")).unwrap_or_default(), fmt_num(at.0), fmt_num(at.1)))),+            1 => hit[0].clone(),+            _ => return Err(OpError::new("ambiguous", format!("{} wires pass through ({}, {}); pass uuid to pick one", hit.len(), fmt_num(at.0), fmt_num(at.1)))),+        };+        let i = self.index_of_uuid(&s.uuid).expect("found above");+        let mut second = self.top()[i].clone();+        set_seg_end(&mut self.top_mut()[i], 1, at);+        let new = new_uuid();+        set_seg_end(&mut second, 0, at);+        if let Some(u) = second.child_mut("uuid") {+            u.set_arg(0, Node::quoted(&new));+        }+        self.top_mut().insert(i + 1, second);+        let j = if junction && self.junction_at(at).is_none() { Some(self.add_junction(at)) } else { None };+        Ok((s.uuid, new, j))+    }++    pub fn add_no_connects(&mut self, pts: &[Pt]) -> Vec<String> {+        pts.iter().map(|p| self.add_no_connect(rp(*p)).expect("infallible")).collect()+    }++    /// Remove top-level items of kind `head` at `p`; returns their uuids.+    pub fn remove_point_items(&mut self, head: &str, p: Pt) -> Vec<String> {+        let doomed: Vec<String> = self.point_items(head).into_iter().filter(|(_, q)| same(*q, p)).map(|(u, _)| u).collect();+        for u in &doomed {+            if let Some(i) = self.index_of_uuid(u) {+                self.top_mut().remove(i);+            }+        }+        doomed+    }++    /// Remove a top-level item by uuid, checking its kind.+    pub fn remove_kind(&mut self, head: &str, uuid: &str) -> R<()> {+        let i = self.index_of_uuid(uuid).ok_or_else(|| OpError::new("not_found", format!("no item with uuid {uuid} on this sheet")))?;+        if self.top()[i].head() != Some(head) {+            return Err(OpError::new("wrong_kind", format!("{uuid} is a {}, not a {head}", self.top()[i].head().unwrap_or("item"))));+        }+        self.top_mut().remove(i);+        Ok(())+    }++    /// Free text: `(text "..." (exclude_from_sim no) (at x y r) (effects ..) (uuid ..))`.+    pub fn add_text(&mut self, text: &str, at: Pt, rotation: f64, size: f64, justify: &[String], bold: bool, italic: bool) -> R<String> {+        if text.is_empty() {+            return Err(OpError::new("bad_arg", "text is required"));+        }+        let rot = norm_rotation(rotation).map_err(|e| OpError::new("bad_arg", e))?;+        if !(size > 0.0 && size < 100.0) {+            return Err(OpError::new("bad_arg", format!("size must be a positive height in mm (got {size})")));+        }+        for j in justify {+            if !["left", "right", "top", "bottom", "mirror"].contains(&j.as_str()) {+                return Err(OpError::new("bad_arg", format!("justify takes left, right, top, bottom (got {j})")));+            }+        }+        let mut font = vec![Node::list("size", vec![Node::number(size), Node::number(size)])];+        if bold {+            font.push(Node::list("bold", vec![Node::atom("yes")]));+        }+        if italic {+            font.push(Node::list("italic", vec![Node::atom("yes")]));+        }+        let mut effects = vec![Node::list("font", font)];+        if !justify.is_empty() {+            effects.push(Node::list("justify", justify.iter().map(|j| Node::atom(j.as_str())).collect()));+        }+        let uuid = new_uuid();+        self.insert_item(Node::list(+            "text",+            vec![+                Node::quoted(text),+                Node::list("exclude_from_sim", vec![Node::atom("no")]),+                Node::list("at", vec![Node::number(r4(at.0)), Node::number(r4(at.1)), Node::number(rot)]),+                Node::list("effects", effects),+                Node::list("uuid", vec![Node::quoted(&uuid)]),+            ],+        ));+        Ok(uuid)+    }++    /// A 45-degree bus entry from `at` to `at + size` (size is (+-2.54, +-2.54) normally).+    pub fn add_bus_entry(&mut self, at: Pt, size: Pt) -> R<String> {+        if size.0.abs() < EPS || size.1.abs() < EPS {+            return Err(OpError::new("bad_arg", "a bus entry needs a non-zero size in both x and y (e.g. [2.54, -2.54])"));+        }+        let uuid = new_uuid();+        self.insert_item(Node::list(+            "bus_entry",+            vec![+                Node::list("at", vec![Node::number(r4(at.0)), Node::number(r4(at.1))]),+                Node::list("size", vec![Node::number(r4(size.0)), Node::number(r4(size.1))]),+                stroke(),+                Node::list("uuid", vec![Node::quoted(&uuid)]),+            ],+        ));+        Ok(uuid)+    }+}++/// Label rotation and justify for a label whose text runs along `dir` away from its point.+pub fn label_rotation_for(dir: Pt) -> f64 {+    if dir.0 > 0.5 {+        0.0+    } else if dir.0 < -0.5 {+        180.0+    } else if dir.1 < -0.5 {+        90.0+    } else {+        270.0+    }+}++// ---------------------------------------------------------------------------+// Schematic: labels+// ---------------------------------------------------------------------------++impl Schematic {+    /// Indices of labels by uuid, or by text (optionally only the one at `at`).+    pub fn label_indices(&self, uuid: Option<&str>, text: Option<&str>, at: Option<Pt>) -> Vec<usize> {+        self.top()+            .iter()+            .enumerate()+            .filter(|(_, n)| n.head().map_or(false, |h| LABEL_HEADS.contains(&h)))+            .filter(|(_, n)| uuid.map_or(true, |u| n.child_value("uuid").as_deref() == Some(u)))+            .filter(|(_, n)| text.map_or(true, |t| n.arg(0).as_deref() == Some(t)))+            .filter(|(_, n)| at.map_or(true, |p| at_of(n).map_or(false, |q| same(p, q))))+            .map(|(i, _)| i)+            .collect()+    }++    /// Set a label's angle and the justify KiCad pairs with it (left for 0 and 90, right for+    /// 180 and 270; local labels keep `bottom`).+    pub fn set_label_rotation(&mut self, idx: usize, rotation: f64) -> R<f64> {+        let rot = norm_rotation(rotation).map_err(|e| OpError::new("bad_arg", e))?;+        let n = &mut self.top_mut()[idx];+        let local = n.head() == Some("label");+        let old = n.child("at").and_then(|a| a.num(2)).unwrap_or(0.0);+        if let Some(a) = n.child_mut("at") {+            if a.children().len() > 3 {+                a.set_arg(2, Node::number(rot));+            } else if let Some(c) = a.children_mut() {+                let mut r = Node::number(rot);+                r.lead = " ".into();+                c.push(r);+            }+        }+        let side = if rot == 180.0 || rot == 270.0 { "right" } else { "left" };+        let mut new_j = vec![Node::atom(side)];+        if local {+            new_j.push(Node::atom("bottom"));+        }+        if let Some(e) = n.child_mut("effects") {+            let keep: Vec<Node> = e.child("justify").map(|j| j.children().iter().skip(1).filter(|c| !matches!(c.raw(), Some("left") | Some("right") | Some("bottom") | Some("top"))).cloned().collect()).unwrap_or_default();+            new_j.extend(keep.into_iter().map(|mut c| {+                c.lead.clear();+                c+            }));+            let mut j = Node::list("justify", new_j);+            let ch = e.children_mut().expect("list");+            if let Some(slot) = ch.iter_mut().find(|c| c.head() == Some("justify")) {+                let lead = slot.lead.clone();+                pretty(&mut j, 0);+                j.lead = lead;+                *slot = j;+            } else {+                // effects sits at depth 2 for a top-level label+                pretty(&mut j, 3);+                ch.push(j);+                if let Kind::List { close_lead, .. } = &mut e.kind {+                    if close_lead.is_empty() {+                        *close_lead = "\n\t\t".into();+                    }+                }+            }+        }+        Ok(old)+    }+}++// ---------------------------------------------------------------------------+// Schematic: moving and rotating symbols+// ---------------------------------------------------------------------------++/// What a stretch did to the wires.+#[derive(Clone, Debug, Default)]+pub struct Stretch {+    pub wires_moved: Vec<String>,+    pub wires_added: Vec<String>,+    pub junctions: Vec<(String, Pt)>,+    pub flags_moved: Vec<String>,+}++impl Schematic {+    /// Move every unit of `reference` by (dx, dy). With `stretch`, wire ends on the old pin+    /// points follow (an H or V wire gets a dogleg so it stays H or V), and no-connect flags,+    /// labels and junctions sitting on those pins move with them.+    pub fn move_units(&mut self, reference: &str, d: Pt, stretch: bool) -> R<(Vec<String>, Stretch)> {+        let idxs = self.require_units(reference)?;+        let old_pins: Vec<Pt> = if stretch { self.pins_of(reference).map(|v| v.into_iter().map(|p| p.at).collect()).unwrap_or_default() } else { vec![] };+        let mut uuids = Vec::new();+        for &i in &idxs {+            let n = &mut self.top_mut()[i];+            shift_node(n, d.0, d.1);+            uuids.push(n.child_value("uuid").unwrap_or_default());+        }+        let mut st = Stretch::default();+        if stretch && !old_pins.is_empty() {+            st = self.stretch_wires(&old_pins, d);+        }+        Ok((uuids, st))+    }++    /// Wire ends at any of `old` move by `d`; point items there move too.+    pub fn stretch_wires(&mut self, old: &[Pt], d: Pt) -> Stretch {+        let on = |p: Pt| old.iter().any(|q| same(*q, p));+        let mut st = Stretch::default();+        let mut new_segs: Vec<(Pt, Pt)> = Vec::new();+        let mut touched_pts: Vec<Pt> = Vec::new();+        let idxs: Vec<usize> = self.top().iter().enumerate().filter(|(_, n)| n.head() == Some("wire")).map(|(i, _)| i).collect();+        for i in idxs {+            let Some(s) = seg_of(&self.top()[i]) else { continue };+            let (ma, mb) = (on(s.a), on(s.b));+            if !ma && !mb {+                continue;+            }+            st.wires_moved.push(s.uuid.clone());+            let n = &mut self.top_mut()[i];+            if ma && mb {+                set_seg_end(n, 0, (s.a.0 + d.0, s.a.1 + d.1));+                set_seg_end(n, 1, (s.b.0 + d.0, s.b.1 + d.1));+                continue;+            }+            let (end, fixed, moving) = if ma { (0, s.b, s.a) } else { (1, s.a, s.b) };+            let new = rp((moving.0 + d.0, moving.1 + d.1));+            let horizontal = (fixed.1 - moving.1).abs() < EPS;+            let vertical = (fixed.0 - moving.0).abs() < EPS;+            let corner = if horizontal { rp((new.0, fixed.1)) } else if vertical { rp((fixed.0, new.1)) } else { new };+            if same(corner, new) || same(corner, fixed) {+                set_seg_end(n, end, new);+            } else {+                set_seg_end(n, end, corner);+                new_segs.push((corner, new));+                touched_pts.push(corner);+            }+            touched_pts.push(new);+        }+        for (a, b) in new_segs {+            if let Ok(u) = self.add_segments("wire", &[a, b]) {+                st.wires_added.extend(u);+            }+        }+        // flags, labels and dots that sat on the pins+        let heads = ["no_connect", "junction", "label", "global_label", "hierarchical_label"];+        let idxs: Vec<usize> = self.top().iter().enumerate().filter(|(_, n)| n.head().map_or(false, |h| heads.contains(&h)) && at_of(n).map_or(false, on)).map(|(i, _)| i).collect();+        for i in idxs {+            let n = &mut self.top_mut()[i];+            shift_node(n, d.0, d.1);+            st.flags_moved.push(n.child_value("uuid").unwrap_or_default());+        }+        st.junctions = self.auto_junctions(&touched_pts);+        st+    }++    /// Rotate every unit of `reference` to the absolute angle `angle` (the first unit's+    /// delta is applied to every unit, each about its own anchor; fields turn with it).+    pub fn rotate_units(&mut self, reference: &str, angle: f64) -> R<(f64, f64, Vec<String>)> {+        let target = norm_rotation(angle).map_err(|e| OpError::new("bad_arg", e))?;+        let idxs = self.require_units(reference)?;+        let first = SymXf::of(&self.top()[idxs[0]]);+        // seen delta: a mirrored unit's stored angle runs the other way+        let delta = if first.mirror.is_some() { first.rot - target } else { target - first.rot };+        let mut uuids = Vec::new();+        for &i in &idxs {+            let n = &mut self.top_mut()[i];+            let xf = SymXf::of(n);+            let new = (((if xf.mirror.is_some() { xf.rot - delta } else { xf.rot + delta }) % 360.0) + 360.0) % 360.0;+            if let Some(a) = n.child_mut("at") {+                if a.children().len() > 3 {+                    a.set_arg(2, Node::number(new));+                } else if let Some(c) = a.children_mut() {+                    let mut r = Node::number(new);+                    r.lead = " ".into();+                    c.push(r);+                }+            }+            for prop in n.children_mut().expect("list").iter_mut().filter(|c| c.head() == Some("property")) {+                if let Some(p) = at_of(prop) {+                    let v = rotate_sheet_vec((p.0 - xf.x, p.1 - xf.y), delta);+                    set_at_xy(prop, (xf.x + v.0, xf.y + v.1));+                }+            }+            uuids.push(n.child_value("uuid").unwrap_or_default());+        }+        Ok((first.rot, target, uuids))+    }++    /// Move everything inside a box by (dx, dy). Units by their anchor, wires by each end+    /// inside (a wire crossing the edge stretches), labels, junctions, no-connects, texts,+    /// bus entries and sheets by their point.+    pub fn move_region(&mut self, bx: [f64; 4], d: Pt, wires: bool, labels: bool, junctions: bool) -> Value {+        let mut moved: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();+        let n_items = self.top().len();+        for i in 0..n_items {+            let n = &self.top()[i];+            let head = n.head().unwrap_or("").to_string();+            let key: &'static str = match head.as_str() {+                "symbol" if is_symbol_instance(n) => "symbols",+                "wire" | "bus" if wires => {+                    let Some(s) = seg_of(n) else { continue };+                    let (ia, ib) = (in_box(s.a, bx), in_box(s.b, bx));+                    if !ia && !ib {+                        continue;+                    }+                    let n = &mut self.top_mut()[i];+                    if ia {+                        set_seg_end(n, 0, (s.a.0 + d.0, s.a.1 + d.1));+                    }+                    if ib {+                        set_seg_end(n, 1, (s.b.0 + d.0, s.b.1 + d.1));+                    }+                    moved.entry(if head == "bus" { "buses" } else { "wires" }).or_default().push(s.uuid);+                    continue;+                }+                "label" | "global_label" | "hierarchical_label" if labels => "labels",+                "junction" if junctions => "junctions",+                "no_connect" if junctions => "noConnects",+                "bus_entry" if wires => "busEntries",+                "text" if labels => "texts",+                "sheet" => "sheets",+                _ => continue,+            };+            let Some(p) = at_of(n) else { continue };+            if !in_box(p, bx) {+                continue;+            }+            let n = &mut self.top_mut()[i];+            shift_node(n, d.0, d.1);+            moved.entry(key).or_default().push(n.child_value("uuid").unwrap_or_default());+        }+        let mut v = serde_json::Map::new();+        for (k, list) in moved {+            v.insert(k.to_string(), json!(list));+        }+        Value::Object(v)+    }++    /// Put fields back at the library symbol's offsets through the unit's transform (and the+    /// library's angle and justify). Returns the fields reset per unit.+    pub fn reset_fields(&mut self, reference: &str, names: &[String]) -> R<Vec<String>> {+        let idxs = self.require_units(reference)?;+        let mut done = Vec::new();+        for &i in &idxs {+            let key = lib_key(&self.top()[i]);+            let lib = self.lib_symbol(&key).ok_or_else(|| OpError::new("lib_symbol_missing", format!("{reference}: library symbol {key} is not in lib_symbols")))?.clone();+            let n = &mut self.top_mut()[i];+            let xf = SymXf::of(n);+            let unit = inst_unit(n);+            for prop in n.children_mut().expect("list").iter_mut().filter(|c| c.head() == Some("property")) {+                let name = prop.arg(0).unwrap_or_default();+                if !names.iter().any(|x| x == &name) {+                    continue;+                }+                let Some(lp) = lib.children().iter().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some(name.as_str())) else { continue };+                let Some(la) = lp.child("at") else { continue };+                let off = (la.num(0).unwrap_or(0.0), la.num(1).unwrap_or(0.0));+                let p = xf.point(off);+                let angle = la.num(2).unwrap_or(0.0);+                if let Some(a) = prop.child_mut("at") {+                    a.set_arg(0, Node::number(p.0));+                    a.set_arg(1, Node::number(p.1));+                    if a.children().len() > 3 {+                        a.set_arg(2, Node::number(angle));+                    }+                }+                // the library's justify, keeping the instance's font and hide+                let lib_j = lp.child("effects").and_then(|e| e.child("justify")).cloned();+                if let Some(e) = prop.child_mut("effects") {+                    let ch = e.children_mut().expect("list");+                    let pos = ch.iter().position(|c| c.head() == Some("justify"));+                    match (pos, lib_j) {+                        (Some(k), Some(mut j)) => {+                            j.lead = ch[k].lead.clone();+                            ch[k] = j;+                        }+                        (Some(k), None) => {+                            ch.remove(k);+                        }+                        (None, Some(mut j)) => {+                            pretty(&mut j, 4);+                            let at = ch.iter().position(|c| c.head() == Some("hide")).unwrap_or(ch.len());+                            ch.insert(at, j);+                        }+                        (None, None) => {}+                    }+                }+                done.push(format!("{unit}:{name}"));+            }+        }+        Ok(done)+    }+}++// ---------------------------------------------------------------------------+// Schematic: library-level edits+// ---------------------------------------------------------------------------++/// What happened to one placed unit's pin list.+#[derive(Clone, Debug, Default)]+pub struct PinSync {+    pub added: Vec<String>,+    pub removed: Vec<String>,+}++fn sync_instance_pins(inst: &mut Node, want: &[String]) -> PinSync {+    let ch = inst.children_mut().expect("list");+    let have: Vec<String> = ch.iter().filter(|c| c.head() == Some("pin")).filter_map(|c| c.arg(0)).collect();+    let mut sync = PinSync::default();+    for h in &have {+        if !want.contains(h) {+            sync.removed.push(h.clone());+        }+    }+    ch.retain(|c| !(c.head() == Some("pin") && c.arg(0).map_or(false, |a| !want.contains(&a))));+    let insert_at = ch.iter().rposition(|c| c.head() == Some("pin")).map(|i| i + 1).unwrap_or_else(|| ch.iter().position(|c| c.head() == Some("instances")).unwrap_or(ch.len()));+    let mut k = insert_at;+    for w in want {+        if !have.contains(w) {+            let mut p = Node::list("pin", vec![Node::quoted(w), Node::list("uuid", vec![Node::quoted(&new_uuid())])]);+            pretty(&mut p, 2);+            ch.insert(k, p);+            k += 1;+            sync.added.push(w.clone());+        }+    }+    sync+}++/// The outcome of a replace.+#[derive(Clone, Debug)]+pub struct Replaced {+    pub old_lib_id: String,+    pub units: Vec<String>,+    pub lib_symbol_added: bool,+    pub pins_added: Vec<String>,+    pub pins_removed: Vec<String>,+    pub old_entry_pruned: bool,+}++impl Schematic {+    /// Point every unit of `reference` at `new_lib_id`, keeping unit, fields and position.+    /// Refuses (pins_lost) when a pin number the units carry would disappear, unless `force`.+    pub fn replace_symbol(&mut self, reference: &str, new_lib_id: &str, block: Option<&Node>, force: bool) -> R<Replaced> {+        let idxs = self.require_units(reference)?;+        let old_key = lib_key(&self.top()[idxs[0]]);+        let old_lib_id = self.top()[idxs[0]].child_value("lib_id").unwrap_or_default();+        let mut added = false;+        let new_entry = match self.lib_symbol(new_lib_id) {+            Some(n) => n.clone(),+            None => {+                let b = block.ok_or_else(|| OpError::new("symbol_not_found", format!("no lib_symbols entry for {new_lib_id} and no library block supplied")))?;+                let mut probe = b.clone();+                probe.set_arg(0, Node::quoted(new_lib_id));+                probe+            }+        };+        let base = base_name(new_lib_id);+        let units = lib_unit_count(&new_entry, &base);+        let mut plan: Vec<(usize, Vec<String>)> = Vec::new();+        let mut lost: Vec<String> = Vec::new();+        let mut gained: Vec<String> = Vec::new();+        for &i in &idxs {+            let inst = &self.top()[i];+            let u = inst_unit(inst);+            if u > units {+                return Err(OpError::new("unit_missing", format!("{reference} places unit {u} but {new_lib_id} has {units} unit(s)")));+            }+            let have: Vec<String> = inst.children().iter().filter(|c| c.head() == Some("pin")).filter_map(|c| c.arg(0)).collect();+            let want = lib_symbol_pins(&new_entry, &base, u);+            for h in &have {+                if !want.contains(h) && !lost.contains(h) {+                    lost.push(h.clone());+                }+            }+            for w in &want {+                if !have.contains(w) && !gained.contains(w) {+                    gained.push(w.clone());+                }+            }+            plan.push((i, want));+        }+        if !lost.is_empty() && !force {+            return Err(OpError::new("pins_lost", format!("{new_lib_id} has no pin {} that {reference} carries now; wires on those pins would dangle (pass force:true to replace anyway)", lost.join(", "))));+        }+        if self.lib_symbol(new_lib_id).is_none() {+            added = self.add_lib_symbol(new_lib_id, block.expect("checked"))?;+        }+        let mut uuids = Vec::new();+        for (i, want) in plan {+            let n = &mut self.top_mut()[i];+            if let Some(l) = n.child_mut("lib_id") {+                l.set_arg(0, Node::quoted(new_lib_id));+            }+            n.children_mut().expect("list").retain(|c| c.head() != Some("lib_name"));+            sync_instance_pins(n, &want);+            uuids.push(n.child_value("uuid").unwrap_or_default());+        }+        let pruned = old_key != new_lib_id && self.prune_lib_symbol(&old_key);+        Ok(Replaced { old_lib_id, units: uuids, lib_symbol_added: added, pins_added: gained, pins_removed: lost, old_entry_pruned: pruned })+    }++    /// Drop a lib_symbols entry no placed unit uses. True when removed.+    pub fn prune_lib_symbol(&mut self, key: &str) -> bool {+        if self.top().iter().any(|n| is_symbol_instance(n) && lib_key(n) == key) {+            return false;+        }+        let Some(ls) = self.doc.root.child_mut("lib_symbols") else { return false };+        let ch = ls.children_mut().expect("list");+        let before = ch.len();+        ch.retain(|n| !(n.head() == Some("symbol") && n.arg(0).as_deref() == Some(key)));+        ch.len() != before+    }++    /// lib_symbols keys in file order.+    pub fn lib_symbol_keys(&self) -> Vec<String> {+        self.doc.root.child("lib_symbols").map(|ls| ls.children().iter().filter(|n| n.head() == Some("symbol")).filter_map(|n| n.arg(0)).collect()).unwrap_or_default()+    }++    /// Replace the embedded definition `key` with `block` (renamed to `key`) when they+    /// differ, and resync the pin lists of every unit that uses it. Returns None when the+    /// definitions already match; refuses (pins_lost) when placed pins would vanish unless+    /// `force`.+    pub fn update_lib_symbol(&mut self, key: &str, block: &Node, force: bool) -> R<Option<PinSync>> {+        let old = self.lib_symbol(key).ok_or_else(|| OpError::new("not_found", format!("{key} is not in lib_symbols")))?;+        let mut entry = block.clone();+        entry.set_arg(0, Node::quoted(key));+        if canon(old) == canon(&entry) {+            return Ok(None);+        }+        let base = base_name(key);+        let users: Vec<usize> = self.top().iter().enumerate().filter(|(_, n)| is_symbol_instance(n) && lib_key(n) == key).map(|(i, _)| i).collect();+        let mut lost: Vec<String> = Vec::new();+        for &i in &users {+            let inst = &self.top()[i];+            let want = lib_symbol_pins(&entry, &base, inst_unit(inst));+            for h in inst.children().iter().filter(|c| c.head() == Some("pin")).filter_map(|c| c.arg(0)) {+                if !want.contains(&h) && !lost.contains(&h) {+                    lost.push(format!("{}.{h}", symbol_property(inst, "Reference").unwrap_or_default()));+                }+            }+        }+        if !lost.is_empty() && !force {+            return Err(OpError::new("pins_lost", format!("the library's {key} drops pins placed on this sheet ({}); pass force:true to update anyway", lost.join(", "))));+        }+        pretty(&mut entry, 2);+        let ls = self.doc.root.child_mut("lib_symbols").expect("entry exists");+        let ch = ls.children_mut().expect("list");+        let k = ch.iter().position(|n| n.head() == Some("symbol") && n.arg(0).as_deref() == Some(key)).expect("entry exists");+        ch[k] = entry.clone();+        let mut sync = PinSync::default();+        for i in users {+            let want = lib_symbol_pins(&entry, &base, inst_unit(&self.top()[i]));+            let s = sync_instance_pins(&mut self.top_mut()[i], &want);+            sync.added.extend(s.added);+            sync.removed.extend(s.removed);+        }+        sync.added.sort();+        sync.added.dedup();+        sync.removed.sort();+        sync.removed.dedup();+        Ok(Some(sync))+    }+}++// ---------------------------------------------------------------------------+// Annotation+// ---------------------------------------------------------------------------++/// `R12` -> ("R", Some(12)); `R?` -> ("R", None); `#PWR03` -> ("#PWR", Some(3)).+pub fn split_reference(r: &str) -> (String, Option<u32>) {+    let t = r.trim();+    if let Some(p) = t.strip_suffix('?') {+        return (p.to_string(), None);+    }+    let digits = t.chars().rev().take_while(|c| c.is_ascii_digit()).count();+    if digits == 0 {+        return (t.to_string(), None);+    }+    let (p, n) = t.split_at(t.len() - digits);+    (p.to_string(), n.parse().ok())+}++/// One planned renumbering.+#[derive(Clone, Debug, serde::Serialize)]+pub struct AnnotateStep {+    pub uuid: String,+    pub from: String,+    pub to: String,+    pub unit: u32,+    pub at: [f64; 2],+    #[serde(rename = "libId")]+    pub lib_id: String,+}++impl Schematic {+    /// Every reference on this sheet (Reference fields and instance records).+    pub fn references_used(&self) -> BTreeSet<String> {+        let mut out = BTreeSet::new();+        for n in self.top().iter().filter(|n| is_symbol_instance(n)) {+            if let Some(r) = symbol_property(n, "Reference") {+                out.insert(r);+            }+            if let Some(inst) = n.child("instances") {+                for proj in inst.children().iter().filter(|c| c.head() == Some("project")) {+                    for path in proj.children().iter().filter(|c| c.head() == Some("path")) {+                        if let Some(r) = path.child_value("reference") {+                            out.insert(r);+                        }+                    }+                }+            }+        }+        out+    }++    /// Plan numbers for every `X?` reference: the first number per prefix free in `used`+    /// (references from every sheet of the project), in position order (x then y, or y then+    /// x). The units of an unannotated multi-unit part are packed into one designator per+    /// lib id and value, the way eeschema does.+    pub fn annotate_plan(&self, used: &BTreeSet<String>, by_y: bool) -> Vec<AnnotateStep> {+        let mut taken: BTreeMap<String, BTreeSet<u32>> = BTreeMap::new();+        for r in used {+            if let (p, Some(n)) = split_reference(r) {+                taken.entry(p).or_default().insert(n);+            }+        }+        let mut pending: Vec<(usize, String, Pt, u32, String, String)> = self+            .top()+            .iter()+            .enumerate()+            .filter(|(_, n)| is_symbol_instance(n))+            .filter_map(|(i, n)| {+                let r = symbol_property(n, "Reference")?;+                if !r.trim().ends_with('?') {+                    return None;+                }+                let xf = SymXf::of(n);+                Some((i, split_reference(&r).0, (xf.x, xf.y), inst_unit(n), n.child_value("lib_id").unwrap_or_default(), symbol_property(n, "Value").unwrap_or_default()))+            })+            .collect();+        pending.sort_by(|a, b| {+            let (ka, kb) = if by_y { ((a.2 .1, a.2 .0), (b.2 .1, b.2 .0)) } else { (a.2, b.2) };+            ka.partial_cmp(&kb).unwrap_or(std::cmp::Ordering::Equal)+        });+        // open packages of multi-unit parts: (prefix, lib id, value) -> [(number, units used)]+        let mut packages: BTreeMap<(String, String, String), Vec<(u32, BTreeSet<u32>, u32)>> = BTreeMap::new();+        let mut out = Vec::new();+        for (i, prefix, at, unit, lib_id, value) in pending {+            let n = &self.top()[i];+            let key = lib_key(n);+            let units = self.lib_symbol(&key).map(|l| lib_unit_count(l, &base_name(&key))).unwrap_or(1);+            let free = |taken: &mut BTreeMap<String, BTreeSet<u32>>| -> u32 {+                let set = taken.entry(prefix.clone()).or_default();+                let mut k = 1;+                while set.contains(&k) {+                    k += 1;+                }+                set.insert(k);+                k+            };+            let num = if units > 1 {+                let pk = packages.entry((prefix.clone(), lib_id.clone(), value.clone())).or_default();+                if let Some(slot) = pk.iter_mut().find(|(_, us, total)| !us.contains(&unit) && (us.len() as u32) < *total) {+                    slot.1.insert(unit);+                    slot.0+                } else {+                    let k = free(&mut taken);+                    let mut s = BTreeSet::new();+                    s.insert(unit);+                    packages.get_mut(&(prefix.clone(), lib_id.clone(), value.clone())).expect("inserted").push((k, s, units));+                    k+                }+            } else {+                free(&mut taken)+            };+            let to = if prefix.starts_with('#') { format!("{prefix}{num:02}") } else { format!("{prefix}{num}") };+            out.push(AnnotateStep { uuid: n.child_value("uuid").unwrap_or_default(), from: symbol_property(n, "Reference").unwrap_or_default(), to, unit, at: [at.0, at.1], lib_id });+        }+        out+    }++    /// Set one placed unit's Reference field and every instance record's reference.+    pub fn set_unit_reference(&mut self, uuid: &str, reference: &str) -> R<()> {+        let i = self.find_uuid_index(uuid).ok_or_else(|| OpError::new("not_found", format!("no item with uuid {uuid}")))?;+        let n = &mut self.top_mut()[i];+        let ch = n.children_mut().expect("list");+        if let Some(p) = ch.iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some("Reference")) {+            p.set_arg(1, Node::quoted(reference));+        }+        if let Some(inst) = ch.iter_mut().find(|c| c.head() == Some("instances")) {+            for proj in inst.children_mut().expect("list").iter_mut().filter(|c| c.head() == Some("project")) {+                for path in proj.children_mut().expect("list").iter_mut().filter(|c| c.head() == Some("path")) {+                    if let Some(r) = path.child_mut("reference") {+                        r.set_arg(0, Node::quoted(reference));+                    }+                }+            }+        }+        Ok(())+    }+}++/// The sheets of the project `sheet` belongs to: the root (`<project>.kicad_sch` beside the+/// `.kicad_pro`) and everything its `(sheet ..)` items reach, plus `sheet` itself.+pub fn project_sheets(sheet: &Path) -> Vec<PathBuf> {+    let dir = sheet.parent().map(Path::to_path_buf).unwrap_or_default();+    let mut out: Vec<PathBuf> = Vec::new();+    let mut queue: Vec<PathBuf> = Vec::new();+    if let Some(stem) = project_name_for(sheet) {+        let root = dir.join(format!("{stem}.kicad_sch"));+        if root.is_file() {+            queue.push(root);+        }+    }+    queue.push(sheet.to_path_buf());+    while let Some(p) = queue.pop() {+        let canon_p = std::fs::canonicalize(&p).unwrap_or_else(|_| p.clone());+        if out.iter().any(|q| std::fs::canonicalize(q).unwrap_or_else(|_| q.clone()) == canon_p) {+            continue;+        }+        let Ok(text) = std::fs::read_to_string(&p) else { continue };+        out.push(p.clone());+        let Ok(s) = Schematic::parse(&text) else { continue };+        let base = p.parent().map(Path::to_path_buf).unwrap_or_default();+        for n in s.top().iter().filter(|n| n.head() == Some("sheet")) {+            if let Some(f) = symbol_property(n, "Sheetfile").or_else(|| symbol_property(n, "Sheet file")) {+                queue.push(base.join(f.replace('\\', "/")));+            }+        }+    }+    out+}++// ---------------------------------------------------------------------------+// Small builders the verbs share+// ---------------------------------------------------------------------------++/// Two-point L route from `a` to `b`: straight when aligned, else the corner that leaves+/// `a` along its own axis (`a_out` horizontal -> go horizontal first). `first` forces "h"+/// or "v".+pub fn l_route(a: Pt, a_out: Pt, b: Pt, first: Option<&str>) -> Vec<Pt> {+    if (a.0 - b.0).abs() < EPS || (a.1 - b.1).abs() < EPS {+        return vec![a, b];+    }+    let h_first = match first {+        Some("h") | Some("horizontal") => true,+        Some("v") | Some("vertical") => false,+        _ => a_out.0.abs() > 0.5,+    };+    let corner = if h_first { (b.0, a.1) } else { (a.0, b.1) };+    vec![a, rp(corner), b]+}++/// A label node text for diagnostics.+pub fn describe_pt(p: Pt) -> String {+    format!("({}, {})", fmt_num(p.0), fmt_num(p.1))+}++/// Quote helper re-exported for the verb layer.+pub fn q(s: &str) -> String {+    quote(s)+}++/// A hidden property node (re-exported builder).+pub fn hidden_property(name: &str, value: &str, at: Pt) -> Node {+    property_node(name, value, at, None, true)+}++/// The default font node (re-exported builder).+pub fn default_font() -> Node {+    font_node()+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn segment_geometry() {+        assert!(on_interior((5.0, 0.0), (0.0, 0.0), (10.0, 0.0)));+        assert!(!on_interior((0.0, 0.0), (0.0, 0.0), (10.0, 0.0)));+        assert!(on_segment((0.0, 0.0), (0.0, 0.0), (10.0, 0.0)));+        assert!(!on_segment((11.0, 0.0), (0.0, 0.0), (10.0, 0.0)));+        assert!(!on_segment((5.0, 0.1), (0.0, 0.0), (10.0, 0.0)));+        assert!(on_interior((2.54, 2.54), (0.0, 0.0), (5.08, 5.08)));+    }++    #[test]+    fn transform_matches_kicad_order() {+        let xf = SymXf { x: 100.0, y: 50.0, rot: 90.0, mirror: None };+        // a pin 3.81 above the anchor in the library (y up) rotated 90 ccw lands to the left+        assert_eq!(xf.point((0.0, 3.81)), (96.19, 50.0));+        let m = SymXf { x: 100.0, y: 50.0, rot: 0.0, mirror: Some('x') };+        assert_eq!(m.point((0.0, 3.81)), (100.0, 53.81));+        assert_eq!(rotate_sheet_vec((1.0, 0.0), 90.0), (0.0, -1.0));+    }++    #[test]+    fn references_split() {+        assert_eq!(split_reference("R12"), ("R".to_string(), Some(12)));+        assert_eq!(split_reference("R?"), ("R".to_string(), None));+        assert_eq!(split_reference("#PWR03"), ("#PWR".to_string(), Some(3)));+    }++    #[test]+    fn l_routes() {+        assert_eq!(l_route((0.0, 0.0), (1.0, 0.0), (10.0, 5.0), None), vec![(0.0, 0.0), (10.0, 0.0), (10.0, 5.0)]);+        assert_eq!(l_route((0.0, 0.0), (0.0, 1.0), (10.0, 5.0), None), vec![(0.0, 0.0), (0.0, 5.0), (10.0, 5.0)]);+        assert_eq!(l_route((0.0, 0.0), (0.0, 1.0), (10.0, 0.0), None).len(), 2);+    }++    #[test]+    fn snaps_to_grid() {+        assert_eq!(snap((1.3, 2.5)), (1.27, 2.54));+    }+}
rust/crates/kicad-core/src/sch_hier.rsadded+1025
@@ -0,0 +1,1025 @@+//! Schematic files and hierarchy: a blank KiCad 10 sheet, paper and title block, the sheet+//! tree of a project, and the sheet operations (add, edit, move, delete, duplicate, sheet+//! pins, page renumbering).+//!+//! The tree is walked from the root sheet. Every file is loaded once (`Hierarchy::docs`); a+//! file used by two sheet symbols appears as two instances that share one document, which+//! is how eeschema stores reuse: one file, one `(instances (project (path ..)))` entry per+//! use. Instance paths are eeschema's: `/<root uuid>` for the root, then one `/<sheet+//! uuid>` per level.+//!+//! Edits work on the in-memory documents; the caller writes the changed ones+//! (`Hierarchy::changed`) with `Schematic::save_with_backup`, or skips that for a dry run.++use std::collections::{BTreeSet, HashMap};+use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::schematic::{fmt_num, is_symbol_instance, new_uuid, pretty, symbol_property, Kind, Node, Schematic};+use crate::sch_model::{self, SheetRef};++/// The `(version ..)` eeschema 10.0 writes.+pub const SCH_VERSION: &str = "20260306";++// ---------------------------------------------------------------------------+// Paper and title block+// ---------------------------------------------------------------------------++/// KiCad's paper names with their landscape size in millimetres.+pub const PAPERS: &[(&str, f64, f64)] = &[+    ("A5", 210.0, 148.0),+    ("A4", 297.0, 210.0),+    ("A3", 420.0, 297.0),+    ("A2", 594.0, 420.0),+    ("A1", 841.0, 594.0),+    ("A0", 1189.0, 841.0),+    ("A", 279.4, 215.9),+    ("B", 431.8, 279.4),+    ("C", 558.8, 431.8),+    ("D", 863.6, 558.8),+    ("E", 1117.6, 863.6),+    ("USLetter", 279.4, 215.9),+    ("USLegal", 355.6, 215.9),+    ("USLedger", 431.8, 279.4),+];++#[derive(Clone, Debug, PartialEq)]+pub struct Paper {+    /// KiCad's name (A4, USLetter, ...) or "User" for a custom size.+    pub name: String,+    /// Custom size in mm (User only).+    pub custom: Option<(f64, f64)>,+    pub portrait: bool,+}++impl Paper {+    /// Accepts KiCad's names case-insensitively plus Letter, Legal, Ledger, Tabloid and+    /// "custom"/"user" with a width and height in mm.+    pub fn parse(name: &str, width: Option<f64>, height: Option<f64>, portrait: bool) -> Result<Paper, String> {+        let n = name.trim().to_ascii_lowercase().replace([' ', '-', '_'], "");+        let alias = match n.as_str() {+            "letter" | "usletter" => "USLetter",+            "legal" | "uslegal" => "USLegal",+            "ledger" | "tabloid" | "usledger" => "USLedger",+            "custom" | "user" => "User",+            _ => "",+        };+        if alias == "User" || (n.is_empty() && width.is_some()) {+            let (Some(w), Some(h)) = (width, height) else {+                return Err("a custom paper needs width and height in millimetres".into());+            };+            if !(10.0..=4000.0).contains(&w) || !(10.0..=4000.0).contains(&h) {+                return Err(format!("custom paper {w} x {h} mm is out of range (10 to 4000 mm per side)"));+            }+            return Ok(Paper { name: "User".into(), custom: Some((w, h)), portrait: false });+        }+        let key = if alias.is_empty() { name.trim() } else { alias };+        let found = PAPERS.iter().find(|(p, _, _)| p.eq_ignore_ascii_case(key)).ok_or_else(|| {+            format!("unknown paper {name}; use one of {} or custom with width and height", PAPERS.iter().map(|p| p.0).collect::<Vec<_>>().join(", "))+        })?;+        Ok(Paper { name: found.0.into(), custom: None, portrait })+    }++    /// Size as drawn (portrait swaps), in mm.+    pub fn size_mm(&self) -> (f64, f64) {+        if let Some(c) = self.custom {+            return c;+        }+        let (w, h) = PAPERS.iter().find(|p| p.0 == self.name).map(|p| (p.1, p.2)).unwrap_or((297.0, 210.0));+        if self.portrait { (h, w) } else { (w, h) }+    }++    pub fn node(&self) -> Node {+        let mut c = vec![Node::quoted(&self.name)];+        if let Some((w, h)) = self.custom {+            c.push(Node::number(w));+            c.push(Node::number(h));+        } else if self.portrait {+            c.push(Node::atom("portrait"));+        }+        let mut n = Node::list("paper", c);+        pretty(&mut n, 1);+        n+    }++    pub fn of(sch: &Schematic) -> Paper {+        let Some(p) = sch.doc.root.child("paper") else { return Paper { name: "A4".into(), custom: None, portrait: false } };+        let name = p.arg(0).unwrap_or_else(|| "A4".into());+        if name == "User" {+            return Paper { name, custom: Some((p.num(1).unwrap_or(297.0), p.num(2).unwrap_or(210.0))), portrait: false };+        }+        let portrait = p.children().iter().any(|c| c.raw() == Some("portrait"));+        Paper { name, custom: None, portrait }+    }++    pub fn json(&self) -> Value {+        let (w, h) = self.size_mm();+        json!({"paper": self.name, "portrait": self.portrait, "widthMm": w, "heightMm": h})+    }+}++/// Title block fields to set; None leaves a field alone, Some("") removes it.+#[derive(Clone, Debug, Default)]+pub struct TitleEdit {+    pub title: Option<String>,+    pub date: Option<String>,+    pub rev: Option<String>,+    pub company: Option<String>,+    /// (comment number 1..9, text)+    pub comments: Vec<(u32, String)>,+}++impl TitleEdit {+    pub fn is_empty(&self) -> bool {+        self.title.is_none() && self.date.is_none() && self.rev.is_none() && self.company.is_none() && self.comments.is_empty()+    }+}++pub fn title_block_json(sch: &Schematic) -> Value {+    let mut v = json!({});+    if let Some(tb) = sch.doc.root.child("title_block") {+        for k in ["title", "date", "rev", "company"] {+            if let Some(x) = tb.child_value(k) {+                v[k] = json!(x);+            }+        }+        let mut comments = serde_json::Map::new();+        for c in tb.children().iter().filter(|c| c.head() == Some("comment")) {+            if let (Some(n), Some(t)) = (c.arg(0), c.arg(1)) {+                comments.insert(n, json!(t));+            }+        }+        if !comments.is_empty() {+            v["comments"] = Value::Object(comments);+        }+    }+    v+}++/// Index after which a header item goes: after the last of `after` present at the top.+fn header_slot(top: &[Node], after: &[&str]) -> usize {+    let mut idx = 1;+    for (i, n) in top.iter().enumerate() {+        if n.head().map_or(false, |h| after.contains(&h)) {+            idx = i + 1;+        }+    }+    idx+}++/// Replace or insert the `(paper ..)` header.+pub fn set_paper(sch: &mut Schematic, p: &Paper) {+    let mut node = p.node();+    pretty(&mut node, 1);+    let top = sch.doc.root.children_mut().expect("root list");+    if let Some(i) = top.iter().position(|n| n.head() == Some("paper")) {+        node.lead = top[i].lead.clone();+        top[i] = node;+    } else {+        let i = header_slot(top, &["version", "generator", "generator_version", "uuid"]);+        top.insert(i, node);+    }+}++/// Apply title block edits; returns the list of changes made.+pub fn set_title_block(sch: &mut Schematic, e: &TitleEdit) -> Vec<String> {+    let mut changes = Vec::new();+    let top = sch.doc.root.children_mut().expect("root list");+    if !top.iter().any(|n| n.head() == Some("title_block")) {+        let mut tb = Node::list("title_block", vec![]);+        pretty(&mut tb, 1);+        let i = header_slot(top, &["version", "generator", "generator_version", "uuid", "paper"]);+        top.insert(i, tb);+    }+    let tb = sch.doc.root.child_mut("title_block").expect("ensured");+    let order = ["title", "date", "rev", "company", "comment"];+    let mut set = |tb: &mut Node, key: &str, num: Option<u32>, val: &str| {+        let children = tb.children_mut().expect("list");+        let pos = children.iter().position(|c| c.head() == Some(key) && num.map_or(true, |n| c.arg(0).as_deref() == Some(n.to_string().as_str())));+        if val.is_empty() {+            if let Some(i) = pos {+                children.remove(i);+                changes.push(format!("removed {}", num.map(|n| format!("comment {n}")).unwrap_or_else(|| key.to_string())));+            }+            return;+        }+        let mut args = Vec::new();+        if let Some(n) = num {+            args.push(Node::atom(n.to_string()));+        }+        args.push(Node::quoted(val));+        let mut node = Node::list(key, args);+        pretty(&mut node, 2);+        let what = num.map(|n| format!("comment {n}")).unwrap_or_else(|| key.to_string());+        match pos {+            Some(i) => {+                children[i] = node;+                changes.push(format!("set {what}"));+            }+            None => {+                // keep eeschema's field order: title, date, rev, company, comment 1..9+                let rank = |c: &Node| -> (usize, u32) {+                    let h = c.head().unwrap_or("");+                    (order.iter().position(|o| *o == h).unwrap_or(9), c.arg(0).and_then(|a| a.parse().ok()).unwrap_or(0))+                };+                let mine = (order.iter().position(|o| *o == key).unwrap_or(9), num.unwrap_or(0));+                let at = children.iter().enumerate().skip(1).find(|(_, c)| rank(c) > mine).map(|(i, _)| i).unwrap_or(children.len());+                children.insert(at, node);+                changes.push(format!("set {what}"));+            }+        }+        if let Kind::List { close_lead, children } = &mut tb.kind {+            if close_lead.is_empty() && children.len() > 1 {+                *close_lead = "\n\t".into();+            }+        }+    };+    for (k, v) in [("title", &e.title), ("date", &e.date), ("rev", &e.rev), ("company", &e.company)] {+        if let Some(v) = v {+            set(tb, k, None, v);+        }+    }+    for (n, t) in &e.comments {+        set(tb, "comment", Some(*n), t);+    }+    changes+}++/// A blank KiCad 10 sheet: header, paper, optional title block, empty lib_symbols, the root+/// sheet_instances entry and embedded_fonts. Returns (text, root uuid).+pub fn blank_sheet(paper: &Paper, title: &TitleEdit) -> (String, String) {+    let uuid = new_uuid();+    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\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"+    );+    let mut sch = Schematic::parse(&text).expect("blank sheet parses");+    set_paper(&mut sch, paper);+    if !title.is_empty() {+        set_title_block(&mut sch, title);+    }+    (sch.to_text(), uuid)+}++/// A minimal `.kicad_pro` KiCad opens and fills in on its first save.+pub fn blank_project(name: &str) -> String {+    let v = json!({+        "meta": {"filename": format!("{name}.kicad_pro"), "version": 3},+        "sheets": [],+        "boards": [],+        "libraries": {"pinned_footprint_libs": [], "pinned_symbol_libs": []},+        "text_variables": {}+    });+    serde_json::to_string_pretty(&v).unwrap_or_default() + "\n"+}++// ---------------------------------------------------------------------------+// The hierarchy+// ---------------------------------------------------------------------------++#[derive(Clone, Debug)]+pub struct Instance {+    /// eeschema instance path: `/<root uuid>` for the root, `/<root uuid>/<sheet uuid>` ...+    pub path: String,+    /// Human path from sheet names: `/`, `/Power/`, `/Power/Filter/`.+    pub human: String,+    pub file: PathBuf,+    pub name: String,+    pub sheet_uuid: Option<String>,+    pub page: Option<String>,+    pub parent: Option<usize>,+    pub depth: usize,+    /// Index into `Hierarchy::docs`; None when the file is missing or unreadable.+    pub doc: Option<usize>,+    pub problem: Option<String>,+    /// The sheet symbol in the parent that opens this instance.+    pub sheet: Option<SheetRef>,+}++pub struct Hierarchy {+    pub root: PathBuf,+    pub docs: Vec<(PathBuf, Schematic)>,+    pub instances: Vec<Instance>,+    pub changed: BTreeSet<usize>,+}++fn same_file(a: &Path, b: &Path) -> bool {+    let ca = std::fs::canonicalize(a).unwrap_or_else(|_| a.to_path_buf());+    let cb = std::fs::canonicalize(b).unwrap_or_else(|_| b.to_path_buf());+    ca == cb+}++/// Resolve a Sheetfile value against the directory of the file that names it.+pub fn resolve_child(parent_file: &Path, sheetfile: &str) -> PathBuf {+    let rel = sheetfile.replace('\\', "/");+    let p = Path::new(&rel);+    if p.is_absolute() || rel.chars().nth(1) == Some(':') {+        return PathBuf::from(rel);+    }+    parent_file.parent().unwrap_or(Path::new(".")).join(p)+}++impl Hierarchy {+    pub fn load(root: &Path) -> Result<Hierarchy, String> {+        let sch = Schematic::load(root)?;+        let root_uuid = sch.root_uuid().ok_or("the root sheet has no (uuid ..)")?;+        let page = sch.doc.root.child("sheet_instances").and_then(|si| si.children().iter().find(|p| p.head() == Some("path") && p.arg(0).as_deref() == Some("/")).and_then(|p| p.child_value("page")));+        let mut h = Hierarchy { root: root.to_path_buf(), docs: vec![(root.to_path_buf(), sch)], instances: Vec::new(), changed: BTreeSet::new() };+        h.instances.push(Instance {+            path: format!("/{root_uuid}"),+            human: "/".into(),+            file: root.to_path_buf(),+            name: "Root".into(),+            sheet_uuid: None,+            page,+            parent: None,+            depth: 0,+            doc: Some(0),+            problem: None,+            sheet: None,+        });+        h.expand(0, &mut vec![root.to_path_buf()]);+        Ok(h)+    }++    fn doc_index(&mut self, file: &Path) -> Result<usize, String> {+        if let Some(i) = self.docs.iter().position(|(p, _)| same_file(p, file)) {+            return Ok(i);+        }+        let s = Schematic::load(file)?;+        self.docs.push((file.to_path_buf(), s));+        Ok(self.docs.len() - 1)+    }++    fn expand(&mut self, inst: usize, stack: &mut Vec<PathBuf>) {+        if self.instances.len() > 2000 {+            return;+        }+        let Some(d) = self.instances[inst].doc else { return };+        let parent_path = self.instances[inst].path.clone();+        let parent_human = self.instances[inst].human.clone();+        let parent_file = self.instances[inst].file.clone();+        let depth = self.instances[inst].depth;+        let sheets: Vec<SheetRef> = self.docs[d].1.doc.root.children().iter().filter(|n| n.head() == Some("sheet")).map(sch_model::sheet_ref).collect();+        for s in sheets {+            let file = resolve_child(&parent_file, &s.file);+            let page = s.pages.iter().find(|(p, _)| *p == parent_path).map(|(_, pg)| pg.clone()).filter(|p| !p.is_empty());+            let mut problem = None;+            let mut doc = None;+            if s.file.trim().is_empty() {+                problem = Some("the sheet names no file".to_string());+            } else if stack.iter().any(|p| same_file(p, &file)) {+                problem = Some("recursive sheet: the file is already an ancestor".to_string());+            } else if !file.is_file() {+                problem = Some("file missing".to_string());+            } else {+                match self.doc_index(&file) {+                    Ok(i) => doc = Some(i),+                    Err(e) => problem = Some(e),+                }+            }+            self.instances.push(Instance {+                path: format!("{parent_path}/{}", s.uuid),+                human: format!("{parent_human}{}/", s.name),+                file: file.clone(),+                name: s.name.clone(),+                sheet_uuid: Some(s.uuid.clone()),+                page,+                parent: Some(inst),+                depth: depth + 1,+                doc,+                problem,+                sheet: Some(s),+            });+            let me = self.instances.len() - 1;+            if doc.is_some() {+                stack.push(file);+                self.expand(me, stack);+                stack.pop();+            }+        }+    }++    pub fn sch(&self, inst: usize) -> Option<&Schematic> {+        self.instances[inst].doc.map(|d| &self.docs[d].1)+    }++    /// Instance paths under which `file` is used.+    pub fn paths_of(&self, file: &Path) -> Vec<String> {+        self.instances.iter().filter(|i| i.doc.is_some() && same_file(&i.file, file)).map(|i| i.path.clone()).collect()+    }++    pub fn doc_of(&self, file: &Path) -> Option<usize> {+        self.docs.iter().position(|(p, _)| same_file(p, file))+    }++    /// Every reference used anywhere in the design, per instance.+    pub fn references(&self) -> BTreeSet<String> {+        let mut out = BTreeSet::new();+        for (i, inst) in self.instances.iter().enumerate() {+            let Some(s) = self.sch(i) else { continue };+            for n in s.doc.root.children().iter().filter(|n| is_symbol_instance(n)) {+                out.insert(sch_model::instance_reference(n, Some(&inst.path)));+            }+        }+        out+    }++    pub fn max_page(&self) -> u32 {+        self.instances.iter().filter_map(|i| i.page.as_deref().and_then(|p| p.parse::<u32>().ok())).max().unwrap_or(1)+    }++    pub fn project_name(&self) -> String {+        if let Some(stem) = self.root.file_stem().map(|s| s.to_string_lossy().to_string()) {+            if self.root.with_extension("kicad_pro").is_file() {+                return stem;+            }+        }+        self.docs[0].1.project_name()+    }++    pub fn tree_json(&self) -> Value {+        fn node(h: &Hierarchy, i: usize) -> Value {+            let inst = &h.instances[i];+            let children: Vec<Value> = h.instances.iter().enumerate().filter(|(_, c)| c.parent == Some(i)).map(|(k, _)| node(h, k)).collect();+            let mut v = json!({+                "name": inst.name,+                "file": inst.file.to_string_lossy().replace('\\', "/"),+                "path": inst.path,+                "sheetPath": inst.human,+                "page": inst.page,+                "depth": inst.depth,+                "exists": inst.doc.is_some(),+            });+            if let Some(u) = &inst.sheet_uuid {+                v["uuid"] = json!(u);+            } else if let Some(s) = h.sch(i) {+                v["uuid"] = json!(s.root_uuid());+            }+            if let Some(s) = &inst.sheet {+                v["at"] = json!([s.at.0, s.at.1]);+                v["size"] = json!([s.size.0, s.size.1]);+                v["fileName"] = json!(s.file);+                v["pins"] = json!(s.pins.iter().map(|p| json!({"name": p.name, "shape": p.shape, "at": [p.at.0, p.at.1], "uuid": p.uuid})).collect::<Vec<_>>());+            }+            if let Some(p) = &inst.problem {+                v["problem"] = json!(p);+            }+            if let Some(s) = h.sch(i) {+                v["symbolCount"] = json!(s.doc.root.children().iter().filter(|n| is_symbol_instance(n) && !symbol_property(n, "Reference").unwrap_or_default().starts_with('#')).count());+            }+            v["sheets"] = json!(children);+            v+        }+        node(self, 0)+    }++    /// Set the page of instance `i` in the document that stores it (the parent's sheet+    /// symbol, or the root's sheet_instances). Returns the previous value.+    pub fn set_page(&mut self, i: usize, page: &str) -> Option<String> {+        let inst = self.instances[i].clone();+        let Some(parent) = inst.parent else {+            let d = self.instances[0].doc?;+            let sch = &mut self.docs[d].1;+            let old = set_root_page(sch, page);+            self.changed.insert(d);+            self.instances[i].page = Some(page.to_string());+            return old;+        };+        let pd = self.instances[parent].doc?;+        let parent_path = self.instances[parent].path.clone();+        let project = self.project_name();+        let sch = &mut self.docs[pd].1;+        let sheet_uuid = inst.sheet_uuid.clone()?;+        let idx = sch.find_uuid_index(&sheet_uuid)?;+        let node = &mut sch.doc.root.children_mut().expect("list")[idx];+        let old = set_sheet_instance_page(node, &project, &parent_path, page);+        self.changed.insert(pd);+        self.instances[i].page = Some(page.to_string());+        old+    }++    /// Depth-first page numbers: root 1, then every sheet in the order the walk met it+    /// (file order within a sheet). Returns (path, old, new) for every instance.+    pub fn renumber(&mut self) -> Vec<(String, Option<String>, String)> {+        let mut order = Vec::new();+        fn dfs(h: &Hierarchy, i: usize, out: &mut Vec<usize>) {+            out.push(i);+            for (k, c) in h.instances.iter().enumerate() {+                if c.parent == Some(i) && c.doc.is_some() {+                    dfs(h, k, out);+                }+            }+        }+        dfs(self, 0, &mut order);+        let mut out = Vec::new();+        for (n, i) in order.into_iter().enumerate() {+            let new = (n + 1).to_string();+            let old = self.instances[i].page.clone();+            if old.as_deref() != Some(new.as_str()) {+                self.set_page(i, &new);+            }+            out.push((self.instances[i].path.clone(), old, new));+        }+        out+    }+}++fn set_root_page(sch: &mut Schematic, page: &str) -> Option<String> {+    let top = sch.doc.root.children_mut().expect("list");+    if !top.iter().any(|n| n.head() == Some("sheet_instances")) {+        let mut si = Node::list("sheet_instances", vec![Node::list("path", vec![Node::quoted("/"), Node::list("page", vec![Node::quoted(page)])])]);+        pretty(&mut si, 1);+        let at = top.iter().position(|n| n.head() == Some("embedded_fonts")).unwrap_or(top.len());+        top.insert(at, si);+        return None;+    }+    let si = sch.doc.root.child_mut("sheet_instances").expect("ensured");+    let children = si.children_mut().expect("list");+    if let Some(p) = children.iter_mut().find(|p| p.head() == Some("path") && p.arg(0).as_deref() == Some("/")) {+        if let Some(pg) = p.child_mut("page") {+            let old = pg.arg(0);+            pg.set_arg(0, Node::quoted(page));+            return old;+        }+    }+    let mut p = Node::list("path", vec![Node::quoted("/"), Node::list("page", vec![Node::quoted(page)])]);+    pretty(&mut p, 2);+    children.push(p);+    None+}++/// Set the page for `path` in a sheet node's instances block, creating entries as needed.+pub fn set_sheet_instance_page(sheet: &mut Node, project: &str, path: &str, page: &str) -> Option<String> {+    let children = sheet.children_mut().expect("list");+    if !children.iter().any(|c| c.head() == Some("instances")) {+        let mut inst = Node::list("instances", vec![Node::list("project", vec![Node::quoted(project), Node::list("path", vec![Node::quoted(path), Node::list("page", vec![Node::quoted(page)])])])]);+        pretty(&mut inst, 2);+        children.push(inst);+        return None;+    }+    let insts = children.iter_mut().find(|c| c.head() == Some("instances")).expect("ensured");+    for proj in insts.children_mut().expect("list").iter_mut().filter(|n| n.head() == Some("project")) {+        for p in proj.children_mut().expect("list").iter_mut().filter(|n| n.head() == Some("path")) {+            if p.arg(0).as_deref() == Some(path) {+                if let Some(pg) = p.child_mut("page") {+                    let old = pg.arg(0);+                    pg.set_arg(0, Node::quoted(page));+                    return old;+                }+                let mut pg = Node::list("page", vec![Node::quoted(page)]);+                pretty(&mut pg, 5);+                let pc = p.children_mut().expect("list");+                pc.push(pg);+                return None;+            }+        }+    }+    // no entry for this path: add one under the first project (or a new project)+    let ic = insts.children_mut().expect("list");+    let mut newp = Node::list("path", vec![Node::quoted(path), Node::list("page", vec![Node::quoted(page)])]);+    if let Some(proj) = ic.iter_mut().find(|n| n.head() == Some("project")) {+        pretty(&mut newp, 4);+        proj.children_mut().expect("list").push(newp);+    } else {+        let mut proj = Node::list("project", vec![Node::quoted(project), newp]);+        pretty(&mut proj, 3);+        ic.push(proj);+    }+    None+}++/// Insert a new top-level item before the trailing blocks, laid out like eeschema.+pub fn insert_top(sch: &mut Schematic, mut node: Node) {+    pretty(&mut node, 1);+    let top = sch.doc.root.children_mut().expect("root list");+    let idx = top.iter().position(|n| n.head().map_or(false, |h| ["sheet_instances", "symbol_instances", "embedded_fonts"].contains(&h))).unwrap_or(top.len());+    top.insert(idx, node);+}++fn font() -> Node {+    Node::list("font", vec![Node::list("size", vec![Node::number(1.27), Node::number(1.27)])])+}++fn field(name: &str, value: &str, at: (f64, f64), justify: &[&str]) -> Node {+    Node::list(+        "property",+        vec![+            Node::quoted(name),+            Node::quoted(value),+            Node::list("at", vec![Node::number(at.0), Node::number(at.1), Node::number(0.0)]),+            Node::list("effects", vec![font(), Node::list("justify", justify.iter().map(|j| Node::atom(*j)).collect())]),+        ],+    )+}++/// Where eeschema puts the two sheet fields for a sheet at `at` of `size`.+fn field_spots(at: (f64, f64), size: (f64, f64)) -> ((f64, f64), (f64, f64)) {+    ((at.0, r(at.1 - 0.7116)), (at.0, r(at.1 + size.1 + 0.5846)))+}++fn r(v: f64) -> f64 {+    (v * 1e4).round() / 1e4 + 0.0+}++/// A new `(sheet ..)` node with its instances entries (one per parent instance path).+pub fn sheet_node(name: &str, file: &str, at: (f64, f64), size: (f64, f64), project: &str, pages: &[(String, String)]) -> (Node, String) {+    let uuid = new_uuid();+    let (name_at, file_at) = field_spots(at, size);+    let paths: Vec<Node> = pages.iter().map(|(p, pg)| Node::list("path", vec![Node::quoted(p), Node::list("page", vec![Node::quoted(pg)])])).collect();+    let mut proj = vec![Node::quoted(project)];+    proj.extend(paths);+    let node = Node::list(+        "sheet",+        vec![+            Node::list("at", vec![Node::number(at.0), Node::number(at.1)]),+            Node::list("size", vec![Node::number(size.0), Node::number(size.1)]),+            Node::list("exclude_from_sim", vec![Node::atom("no")]),+            Node::list("in_bom", vec![Node::atom("yes")]),+            Node::list("on_board", vec![Node::atom("yes")]),+            Node::list("dnp", vec![Node::atom("no")]),+            Node::list("fields_autoplaced", vec![Node::atom("yes")]),+            Node::list("stroke", vec![Node::list("width", vec![Node::number(0.1524)]), Node::list("type", vec![Node::atom("solid")])]),+            Node::list("fill", vec![Node::list("color", vec![Node::atom("0"), Node::atom("0"), Node::atom("0"), Node::atom("0.0000")])]),+            Node::list("uuid", vec![Node::quoted(&uuid)]),+            field("Sheetname", name, name_at, &["left", "bottom"]),+            field("Sheetfile", file, file_at, &["left", "top"]),+            Node::list("instances", vec![Node::list("project", proj)]),+        ],+    );+    (node, uuid)+}++pub fn find_sheet<'a>(sch: &'a Schematic, key: &str) -> Result<usize, String> {+    let top = sch.doc.root.children();+    let hits: Vec<usize> = top+        .iter()+        .enumerate()+        .filter(|(_, n)| n.head() == Some("sheet"))+        .filter(|(_, n)| n.child_value("uuid").as_deref() == Some(key) || symbol_property(n, "Sheetname").map_or(false, |s| s.eq_ignore_ascii_case(key)))+        .map(|(i, _)| i)+        .collect();+    match hits.len() {+        1 => Ok(hits[0]),+        0 => {+            let names: Vec<String> = top.iter().filter(|n| n.head() == Some("sheet")).filter_map(|n| symbol_property(n, "Sheetname")).collect();+            Err(format!("no sheet named or with uuid {key} on this sheet (sheets here: {})", if names.is_empty() { "none".into() } else { names.join(", ") }))+        }+        _ => Err(format!("{} sheets are named {key}; pass the sheet's uuid instead", hits.len())),+    }+}++fn shift(at: &mut Node, dx: f64, dy: f64) {+    let x = at.num(0).unwrap_or(0.0) + dx;+    let y = at.num(1).unwrap_or(0.0) + dy;+    at.set_arg(0, Node::number(r(x)));+    at.set_arg(1, Node::number(r(y)));+}++/// Move a sheet node, its fields and its pins by (dx, dy).+pub fn move_sheet_node(sheet: &mut Node, dx: f64, dy: f64) {+    if let Some(a) = sheet.child_mut("at") {+        shift(a, dx, dy);+    }+    for c in sheet.children_mut().expect("list").iter_mut().filter(|c| c.head() == Some("property") || c.head() == Some("pin")) {+        if let Some(a) = c.child_mut("at") {+            shift(a, dx, dy);+        }+    }+}++/// Resize a sheet; pins on the right and bottom edges follow their edge, fields re-anchor.+pub fn resize_sheet_node(sheet: &mut Node, w: f64, h: f64) -> Result<(), String> {+    if !(2.54..=2000.0).contains(&w) || !(2.54..=2000.0).contains(&h) {+        return Err(format!("sheet size {w} x {h} mm is out of range"));+    }+    let s = sch_model::sheet_ref(sheet);+    let (dx, dy) = (w - s.size.0, h - s.size.1);+    for p in &s.pins {+        let below = p.at.1 > s.at.1 + h + sch_model::EPS && (p.at.1 - (s.at.1 + s.size.1)).abs() > sch_model::EPS;+        if below {+            return Err(format!("pin {} at y {} would fall outside the new size; move or delete it first", p.name, p.at.1));+        }+    }+    if let Some(sz) = sheet.child_mut("size") {+        sz.set_arg(0, Node::number(w));+        sz.set_arg(1, Node::number(h));+    }+    for c in sheet.children_mut().expect("list").iter_mut() {+        match c.head() {+            Some("pin") => {+                let Some(a) = c.child_mut("at") else { continue };+                let (px, py) = (a.num(0).unwrap_or(0.0), a.num(1).unwrap_or(0.0));+                if (px - (s.at.0 + s.size.0)).abs() <= sch_model::EPS {+                    shift(a, dx, 0.0);+                } else if (py - (s.at.1 + s.size.1)).abs() <= sch_model::EPS {+                    shift(a, 0.0, dy);+                }+            }+            Some("property") if c.arg(0).as_deref() == Some("Sheetfile") => {+                if let Some(a) = c.child_mut("at") {+                    shift(a, 0.0, dy);+                }+            }+            _ => {}+        }+    }+    Ok(())+}++pub fn set_sheet_field(sheet: &mut Node, name: &str, value: &str) -> Option<String> {+    let p = sheet.children_mut()?.iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some(name))?;+    let old = p.arg(1);+    p.set_arg(1, Node::quoted(value));+    old+}++// ---------------------------------------------------------------------------+// Sheet pins+// ---------------------------------------------------------------------------++pub const PIN_SHAPES: &[&str] = &["input", "output", "bidirectional", "tri_state", "passive"];++#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum Side {+    Left,+    Right,+    Top,+    Bottom,+}++impl Side {+    pub fn parse(s: &str) -> Option<Side> {+        match s.trim().to_ascii_lowercase().as_str() {+            "left" | "l" | "west" => Some(Side::Left),+            "right" | "r" | "east" => Some(Side::Right),+            "top" | "t" | "north" => Some(Side::Top),+            "bottom" | "b" | "south" => Some(Side::Bottom),+            _ => None,+        }+    }+    pub fn name(self) -> &'static str {+        match self {+            Side::Left => "left",+            Side::Right => "right",+            Side::Top => "top",+            Side::Bottom => "bottom",+        }+    }+    /// eeschema's pin angle and text justification on this edge.+    fn angle_justify(self) -> (f64, &'static str) {+        match self {+            Side::Left => (180.0, "left"),+            Side::Right => (0.0, "right"),+            Side::Top => (90.0, "right"),+            Side::Bottom => (270.0, "left"),+        }+    }+    /// The edge nearest to `p` (the edge a pin sits on, or the one a requested point is closest to).+    pub fn of(s: &SheetRef, p: (f64, f64)) -> Side {+        let d = [+            (Side::Left, (p.0 - s.at.0).abs()),+            (Side::Right, (p.0 - (s.at.0 + s.size.0)).abs()),+            (Side::Top, (p.1 - s.at.1).abs()),+            (Side::Bottom, (p.1 - (s.at.1 + s.size.1)).abs()),+        ];+        d.iter().fold(d[0], |best, x| if x.1 < best.1 { *x } else { best }).0+    }+}++/// The default edge for a pin of this shape: inputs left, outputs right.+pub fn default_side(shape: &str) -> Side {+    if shape == "output" || shape == "tri_state" { Side::Right } else { Side::Left }+}++/// Snap a requested point onto the sheet edge `side` (2.54 mm steps from the corner), or pick+/// the first free 2.54 mm slot on that edge.+pub fn pin_spot(s: &SheetRef, side: Side, want: Option<(f64, f64)>) -> Result<(f64, f64), String> {+    let step = 2.54;+    let along_len = match side {+        Side::Left | Side::Right => s.size.1,+        _ => s.size.0,+    };+    let point = |t: f64| -> (f64, f64) {+        match side {+            Side::Left => (s.at.0, r(s.at.1 + t)),+            Side::Right => (r(s.at.0 + s.size.0), r(s.at.1 + t)),+            Side::Top => (r(s.at.0 + t), s.at.1),+            Side::Bottom => (r(s.at.0 + t), r(s.at.1 + s.size.1)),+        }+    };+    let taken = |p: (f64, f64)| s.pins.iter().any(|q| sch_model::dist(q.at, p) < 1.27);+    if let Some(w) = want {+        let t = match side {+            Side::Left | Side::Right => w.1 - s.at.1,+            _ => w.0 - s.at.0,+        };+        let t = (t / step).round() * step;+        if t <= 0.0 || t >= along_len {+            return Err(format!("the point is off the {} edge (the edge runs {} mm from the sheet corner)", side.name(), fmt_num(along_len)));+        }+        let p = point(t);+        if taken(p) {+            return Err(format!("another pin already sits at ({}, {})", fmt_num(p.0), fmt_num(p.1)));+        }+        return Ok(p);+    }+    let mut t = step;+    while t < along_len - 0.01 {+        let p = point(t);+        if !taken(p) {+            return Ok(p);+        }+        t += step;+    }+    Err(format!("no free 2.54 mm slot left on the {} edge; enlarge the sheet (kicad_sch_sheet action edit, size) or pick another side", side.name()))+}++pub fn sheet_pin_node(name: &str, shape: &str, at: (f64, f64), side: Side) -> (Node, String) {+    let uuid = new_uuid();+    let (angle, justify) = side.angle_justify();+    let node = Node::list(+        "pin",+        vec![+            Node::quoted(name),+            Node::atom(shape),+            Node::list("at", vec![Node::number(at.0), Node::number(at.1), Node::number(angle)]),+            Node::list("uuid", vec![Node::quoted(&uuid)]),+            Node::list("effects", vec![font(), Node::list("justify", vec![Node::atom(justify)])]),+        ],+    );+    (node, uuid)+}++/// Append a pin node to a sheet node (before its instances block), laid out at depth 2.+pub fn push_sheet_pin(sheet: &mut Node, mut pin: Node) {+    pretty(&mut pin, 2);+    let children = sheet.children_mut().expect("list");+    let at = children.iter().position(|c| c.head() == Some("instances")).unwrap_or(children.len());+    children.insert(at, pin);+}++/// Hierarchical labels of a sheet file: (name, shape), first occurrence order.+pub fn hier_labels(sch: &Schematic) -> Vec<(String, String, String)> {+    let mut out: Vec<(String, String, String)> = Vec::new();+    for n in sch.doc.root.children().iter().filter(|n| n.head() == Some("hierarchical_label")) {+        let name = n.arg(0).unwrap_or_default();+        if !out.iter().any(|(x, _, _)| *x == name) {+            out.push((name, n.child_value("shape").unwrap_or_else(|| "input".into()), n.child_value("uuid").unwrap_or_default()));+        }+    }+    out+}++/// Pins vs child labels: (labels with no pin, pins with no label, shape mismatches).+pub fn pin_label_diff(sheet: &SheetRef, labels: &[(String, String, String)]) -> (Vec<Value>, Vec<Value>, Vec<Value>) {+    let mut no_pin = Vec::new();+    let mut no_label = Vec::new();+    let mut mismatch = Vec::new();+    for (name, shape, uuid) in labels {+        match sheet.pins.iter().find(|p| p.name == *name) {+            None => no_pin.push(json!({"name": name, "shape": shape, "labelUuid": uuid})),+            Some(p) if p.shape != *shape => mismatch.push(json!({"name": name, "pinShape": p.shape, "labelShape": shape, "pinUuid": p.uuid})),+            _ => {}+        }+    }+    for p in &sheet.pins {+        if !labels.iter().any(|(n, _, _)| *n == p.name) {+            no_label.push(json!({"name": p.name, "shape": p.shape, "at": [p.at.0, p.at.1], "pinUuid": p.uuid}));+        }+    }+    (no_pin, no_label, mismatch)+}++// ---------------------------------------------------------------------------+// Duplicate support+// ---------------------------------------------------------------------------++/// Give every `(uuid ..)` in the document a fresh value; returns old to new.+pub fn refresh_uuids(sch: &mut Schematic) -> HashMap<String, String> {+    fn walk(n: &mut Node, map: &mut HashMap<String, String>) {+        if n.head() == Some("uuid") {+            if let Some(old) = n.arg(0) {+                let new = map.entry(old).or_insert_with(new_uuid).clone();+                n.set_arg(0, Node::quoted(&new));+            }+            return;+        }+        if let Some(c) = n.children_mut() {+            for k in c.iter_mut() {+                walk(k, map);+            }+        }+    }+    let mut map = HashMap::new();+    walk(&mut sch.doc.root, &mut map);+    map+}++/// Allocates references that are not used yet: R3 taken, R? asks, R4 comes back.+pub struct RefAllocator {+    pub used: BTreeSet<String>,+}++impl RefAllocator {+    pub fn next(&mut self, like: &str) -> String {+        let prefix: String = like.chars().take_while(|c| !c.is_ascii_digit() && *c != '?').collect();+        let mut n = 1;+        loop {+            let cand = format!("{prefix}{n}");+            if !self.used.contains(&cand) {+                self.used.insert(cand.clone());+                return cand;+            }+            n += 1;+        }+    }+}++/// Point every nested sheet's instance entry at `new_path` (the copy's own path) and give it+/// `page`.+pub fn retarget_sheet_instances(sch: &mut Schematic, new_path: &str, project: &str, next_page: &mut u32) {+    for n in sch.doc.root.children_mut().expect("list").iter_mut().filter(|n| n.head() == Some("sheet")) {+        let children = n.children_mut().expect("list");+        if let Some(i) = children.iter().position(|c| c.head() == Some("instances")) {+            children.remove(i);+        }+        *next_page += 1;+        set_sheet_instance_page(n, project, new_path, &next_page.to_string());+    }+}++/// A free file name beside `base`: `<stem>_copy.kicad_sch`, `<stem>_copy2.kicad_sch`, ...+pub fn free_copy_name(dir: &Path, stem: &str) -> String {+    let mut n = 1;+    loop {+        let name = if n == 1 { format!("{stem}_copy.kicad_sch") } else { format!("{stem}_copy{n}.kicad_sch") };+        if !dir.join(&name).exists() {+            return name;+        }+        n += 1;+    }+}++/// The project root sheet for a file: `<stem>.kicad_sch` of the `.kicad_pro` in its folder+/// (when that root exists), else None.+pub fn project_root_for(file: &Path) -> Option<PathBuf> {+    let dir = file.parent()?;+    let mut pros: Vec<PathBuf> = std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path()).filter(|p| p.extension().and_then(|x| x.to_str()) == Some("kicad_pro")).collect();+    pros.sort();+    let stem = file.file_stem()?.to_string_lossy().to_string();+    if let Some(p) = pros.iter().find(|p| p.file_stem().map(|s| s.to_string_lossy() == stem).unwrap_or(false)) {+        let root = p.with_extension("kicad_sch");+        if root.is_file() {+            return Some(root);+        }+    }+    for p in pros {+        let root = p.with_extension("kicad_sch");+        if root.is_file() {+            return Some(root);+        }+    }+    None+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn paper_parse() {+        assert_eq!(Paper::parse("a3", None, None, false).unwrap().name, "A3");+        assert_eq!(Paper::parse("Letter", None, None, true).unwrap().size_mm(), (215.9, 279.4));+        assert_eq!(Paper::parse("tabloid", None, None, false).unwrap().name, "USLedger");+        let c = Paper::parse("custom", Some(200.0), Some(100.0), false).unwrap();+        assert_eq!(c.node().to_text().trim(), "(paper \"User\" 200 100)");+        assert!(Paper::parse("A9", None, None, false).is_err());+        assert!(Paper::parse("custom", Some(200.0), None, false).is_err());+        assert_eq!(Paper::parse("A4", None, None, true).unwrap().node().to_text().trim(), "(paper \"A4\" portrait)");+    }++    #[test]+    fn blank_parses_with_title() {+        let (text, uuid) = blank_sheet(&Paper::parse("A3", None, None, false).unwrap(), &TitleEdit { title: Some("T".into()), comments: vec![(2, "c2".into()), (1, "c1".into())], ..Default::default() });+        let s = Schematic::parse(&text).unwrap();+        assert_eq!(s.root_uuid().unwrap(), uuid);+        assert_eq!(s.version().unwrap(), SCH_VERSION);+        assert_eq!(Paper::of(&s).name, "A3");+        let tb = title_block_json(&s);+        assert_eq!(tb["title"], "T");+        assert_eq!(tb["comments"]["1"], "c1");+        let i1 = text.find("(comment 1").unwrap();+        let i2 = text.find("(comment 2").unwrap();+        assert!(i1 < i2, "comments ordered");+        assert!(text.contains("\t(title_block\n\t\t(title \"T\")"), "{text}");+    }++    #[test]+    fn ref_allocator_skips_used() {+        let mut a = RefAllocator { used: ["R1", "R2", "C1"].iter().map(|s| s.to_string()).collect() };+        assert_eq!(a.next("R1"), "R3");+        assert_eq!(a.next("R?"), "R4");+        assert_eq!(a.next("C5"), "C2");+    }+}
rust/crates/kicad-core/src/sch_model.rsadded+819
@@ -0,0 +1,819 @@+//! Read-only electrical and graphical model of ONE schematic sheet, and its connectivity.+//!+//! `extract` turns a parsed `.kicad_sch` (the lossless `schematic::Schematic`) into flat+//! lists: wire segments, junctions, no-connect flags, labels, symbol pins in sheet+//! coordinates (with their electrical type from `lib_symbols`), sheet symbols with their+//! pins, symbol body extents and visible text. `Connectivity::build` then groups them into+//! nets with the rules eeschema's connection graph uses:+//!+//! * wire ends join other wire ends, and a wire end landing on another wire's interior+//!   joins it (a T);+//! * labels and junction dots join a wire anywhere along it;+//! * a symbol pin or a sheet pin joins a wire at the wire's END, or mid-wire only where a+//!   junction dot sits; pins stacked on one point join each other;+//! * same-named local labels on one sheet are one net, as are same-named global labels and+//!   power symbols (power symbols name their net by their Value), and hidden power-input+//!   pins of ordinary symbols join the global net of their pin name.+//!+//! Coordinates are sheet millimetres, origin top-left, y down. Two points are the same+//! point when they agree to 1 micrometre (eeschema's internal unit is 100 nm).++use std::collections::{BTreeMap, BTreeSet, HashMap};++use serde::Serialize;++use crate::schematic::{is_symbol_instance, symbol_property, Node, Schematic};++pub type Pt = (f64, f64);++/// Same-point tolerance in millimetres.+pub const EPS: f64 = 0.001;++pub fn same(a: Pt, b: Pt) -> bool {+    (a.0 - b.0).abs() <= EPS && (a.1 - b.1).abs() <= EPS+}++pub fn dist(a: Pt, b: Pt) -> f64 {+    ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()+}++/// Round to the 0.1 micrometre grid and drop negative zero, for stable output.+pub fn r4(v: f64) -> f64 {+    (v * 1e4).round() / 1e4 + 0.0+}++/// Is `p` on the segment a-b (endpoints included), within EPS.+pub fn on_segment(p: Pt, a: Pt, b: Pt) -> bool {+    let (dx, dy) = (b.0 - a.0, b.1 - a.1);+    let len2 = dx * dx + dy * dy;+    if len2 == 0.0 {+        return same(p, a);+    }+    let t = ((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len2;+    if t < -1e-9 || t > 1.0 + 1e-9 {+        return same(p, a) || same(p, b);+    }+    let q = (a.0 + t * dx, a.1 + t * dy);+    dist(p, q) <= EPS+}++/// On the segment but not at either end.+pub fn on_interior(p: Pt, a: Pt, b: Pt) -> bool {+    on_segment(p, a, b) && !same(p, a) && !same(p, b)+}++/// An axis-aligned box: (min x, min y, max x, max y).+pub type BBox = (f64, f64, f64, f64);++pub fn bbox_union(a: Option<BBox>, p: Pt) -> Option<BBox> {+    Some(match a {+        None => (p.0, p.1, p.0, p.1),+        Some((x0, y0, x1, y1)) => (x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)),+    })+}++/// Overlap area of two boxes after shrinking each by `inset` (so boxes that only touch,+/// like two symbols wired pin to pin, do not count).+pub fn overlap(a: BBox, b: BBox, inset: f64) -> Option<BBox> {+    let a = (a.0 + inset, a.1 + inset, a.2 - inset, a.3 - inset);+    let b = (b.0 + inset, b.1 + inset, b.2 - inset, b.3 - inset);+    let (x0, y0, x1, y1) = (a.0.max(b.0), a.1.max(b.1), a.2.min(b.2), a.3.min(b.3));+    if x1 > x0 && y1 > y0 {+        Some((x0, y0, x1, y1))+    } else {+        None+    }+}++#[derive(Clone, Debug, Serialize)]+pub struct Wire {+    pub uuid: String,+    pub a: Pt,+    pub b: Pt,+}++#[derive(Clone, Debug, Serialize)]+pub struct Marker {+    pub uuid: String,+    pub at: Pt,+}++#[derive(Clone, Debug, Serialize)]+pub struct Label {+    pub uuid: String,+    pub text: String,+    /// local | global | hierarchical+    pub kind: String,+    pub at: Pt,+    pub rotation: f64,+    #[serde(skip_serializing_if = "Option::is_none")]+    pub shape: Option<String>,+    #[serde(skip)]+    pub size: f64,+}++#[derive(Clone, Debug, Serialize)]+pub struct Pin {+    pub reference: String,+    #[serde(rename = "symbolUuid")]+    pub symbol_uuid: String,+    pub number: String,+    pub name: String,+    /// eeschema's pin electrical type: input, output, bidirectional, tri_state, passive,+    /// free, unspecified, power_in, power_out, open_collector, open_emitter, no_connect.+    pub electrical: String,+    pub at: Pt,+    #[serde(skip_serializing_if = "std::ops::Not::not")]+    pub hidden: bool,+    /// The pin of a power symbol (#PWR, a GND or +3V3 flag): it names a net, it is not a part.+    #[serde(rename = "powerSymbol", skip_serializing_if = "std::ops::Not::not")]+    pub power_symbol: bool,+    /// For a power symbol pin: the net it names (the symbol's Value).+    #[serde(skip)]+    pub power_net: Option<String>,+}++#[derive(Clone, Debug, Serialize)]+pub struct SheetPin {+    pub uuid: String,+    pub name: String,+    pub shape: String,+    pub at: Pt,+    pub rotation: f64,+    #[serde(rename = "sheetUuid")]+    pub sheet_uuid: String,+    #[serde(rename = "sheetName")]+    pub sheet_name: String,+}++#[derive(Clone, Debug, Serialize)]+pub struct SheetRef {+    pub uuid: String,+    pub name: String,+    pub file: String,+    pub at: Pt,+    pub size: Pt,+    pub pins: Vec<SheetPin>,+    /// (instance path of the file holding this sheet, page) pairs from its instances block.+    #[serde(skip)]+    pub pages: Vec<(String, String)>,+}++#[derive(Clone, Debug, Serialize)]+pub struct SymbolMeta {+    pub uuid: String,+    pub reference: String,+    #[serde(rename = "libId")]+    pub lib_id: String,+    pub value: String,+    pub footprint: String,+    pub description: String,+    pub unit: u32,+    pub at: Pt,+    #[serde(rename = "isPower")]+    pub is_power: bool,+    #[serde(rename = "inBom")]+    pub in_bom: bool,+    #[serde(rename = "onBoard")]+    pub on_board: bool,+    pub dnp: bool,+    /// Body and pin extents in sheet coordinates (None when the library symbol is missing).+    #[serde(skip)]+    pub bbox: Option<BBox>,+}++/// Visible text on the sheet: a free (text ..) item or a visible symbol field.+#[derive(Clone, Debug, Serialize)]+pub struct TextItem {+    pub uuid: String,+    pub text: String,+    pub at: Pt,+    /// text | field+    pub kind: String,+    /// For a field: the reference of its symbol.+    #[serde(skip_serializing_if = "Option::is_none")]+    pub owner: Option<String>,+    #[serde(skip)]+    pub bbox: BBox,+}++#[derive(Clone, Debug, Default)]+pub struct SheetModel {+    pub wires: Vec<Wire>,+    pub buses: Vec<Wire>,+    pub junctions: Vec<Marker>,+    pub no_connects: Vec<Marker>,+    pub labels: Vec<Label>,+    pub pins: Vec<Pin>,+    pub sheets: Vec<SheetRef>,+    pub symbols: Vec<SymbolMeta>,+    pub texts: Vec<TextItem>,+    /// Symbols whose library symbol is not in lib_symbols (their pins are unknown).+    pub missing_lib: Vec<String>,+}++impl SheetModel {+    pub fn sheet_pins(&self) -> impl Iterator<Item = &SheetPin> {+        self.sheets.iter().flat_map(|s| s.pins.iter())+    }+}++fn xy_of(n: &Node) -> Option<Pt> {+    Some((n.num(0)?, n.num(1)?))+}++fn at_of(n: &Node) -> (Pt, f64) {+    match n.child("at") {+        Some(a) => ((a.num(0).unwrap_or(0.0), a.num(1).unwrap_or(0.0)), a.num(2).unwrap_or(0.0)),+        None => ((0.0, 0.0), 0.0),+    }+}++fn is_hidden(n: &Node) -> bool {+    if let Some(h) = n.child("hide") {+        return h.arg(0).map_or(true, |v| v == "yes");+    }+    if n.children().iter().any(|c| c.raw() == Some("hide")) {+        return true;+    }+    n.child("effects").map_or(false, |e| {+        e.child("hide").map_or(false, |h| h.arg(0).map_or(true, |v| v == "yes")) || e.children().iter().any(|c| c.raw() == Some("hide"))+    })+}++fn font_size(n: &Node) -> f64 {+    n.child("effects").and_then(|e| e.child("font")).and_then(|f| f.child("size")).and_then(|s| s.num(0)).unwrap_or(1.27)+}++fn yes(n: &Node, name: &str, default: bool) -> bool {+    n.child(name).and_then(|c| c.arg(0)).map(|v| v == "yes").unwrap_or(default)+}++/// A placed symbol's placement: anchor, rotation and mirror, mapping library coordinates+/// (y up) to sheet coordinates (y down). Rotation turns counterclockwise as seen, then the+/// mirror flips: the same order `schematic::pin_points` uses.+#[derive(Clone, Copy, Debug)]+pub struct Placement {+    pub at: Pt,+    pub sin: f64,+    pub cos: f64,+    pub mirror: Option<char>,+}++impl Placement {+    pub fn of(inst: &Node) -> Placement {+        let (at, rot) = at_of(inst);+        let (sin, cos) = rot.to_radians().sin_cos();+        let mirror = inst.child_value("mirror").and_then(|m| m.chars().next());+        Placement { at, sin, cos, mirror }+    }+    pub fn apply(&self, p: Pt) -> Pt {+        let (mut qx, mut qy) = (p.0 * self.cos - p.1 * self.sin, p.0 * self.sin + p.1 * self.cos);+        match self.mirror {+            Some('x') => qy = -qy,+            Some('y') => qx = -qx,+            _ => {}+        }+        (r4(self.at.0 + qx), r4(self.at.1 - qy))+    }+}++/// `R_1_1` with base `R` -> (unit 1, body style 1).+fn sub_unit_style(name: &str, base: &str) -> Option<(u32, u32)> {+    let rest = name.strip_prefix(base)?.strip_prefix('_')?;+    let (u, s) = rest.split_once('_')?;+    Some((u.parse().ok()?, s.parse().ok()?))+}++/// The sub-symbols of a library symbol that a placed unit draws.+fn unit_subs<'a>(lib: &'a Node, lib_key: &str, unit: u32, style: u32) -> Vec<&'a Node> {+    let base = lib.arg(0).unwrap_or_else(|| lib_key.to_string());+    let base = base.rsplit(':').next().unwrap_or(&base).to_string();+    lib.children()+        .iter()+        .filter(|n| n.head() == Some("symbol"))+        .filter(|n| {+            let Some(name) = n.arg(0) else { return false };+            match sub_unit_style(&name, &base) {+                Some((u, s)) => (u == 0 || u == unit) && (s == 0 || s == style),+                None => false,+            }+        })+        .collect()+}++/// The reference a symbol carries under one sheet instance path, else its Reference field.+pub fn instance_reference(inst: &Node, path: Option<&str>) -> String {+    if let Some(path) = path {+        if let Some(insts) = inst.child("instances") {+            for proj in insts.children().iter().filter(|n| n.head() == Some("project")) {+                for p in proj.children().iter().filter(|n| n.head() == Some("path")) {+                    if p.arg(0).as_deref() == Some(path) {+                        if let Some(r) = p.child_value("reference") {+                            return r;+                        }+                    }+                }+            }+        }+    }+    symbol_property(inst, "Reference").unwrap_or_default()+}++/// Rough extent of a text run: KiCad's stroke font averages about 0.9 of the font height per+/// character including spacing; height is the font height. `rot` 0 runs right, 90 up, 180+/// left, 270 down from the anchor; `centered` puts the anchor mid-run (free text, fields).+pub fn text_bbox(at: Pt, text: &str, size: f64, rot: f64, centered: bool, pad: f64) -> BBox {+    let len = text.chars().count().max(1) as f64 * size * 0.9 + pad;+    let h = size;+    let r = ((rot % 360.0) + 360.0) % 360.0;+    let vertical = (r - 90.0).abs() < 1.0 || (r - 270.0).abs() < 1.0;+    let (w, hh) = if vertical { (h, len) } else { (len, h) };+    if centered {+        return (at.0 - w / 2.0, at.1 - hh / 2.0, at.0 + w / 2.0, at.1 + hh / 2.0);+    }+    if (r - 0.0).abs() < 1.0 {+        (at.0, at.1 - h, at.0 + len, at.1)+    } else if (r - 180.0).abs() < 1.0 {+        (at.0 - len, at.1 - h, at.0, at.1)+    } else if (r - 90.0).abs() < 1.0 {+        (at.0 - h, at.1 - len, at.0, at.1)+    } else {+        (at.0 - h, at.1, at.0, at.1 + len)+    }+}++/// Build the model of one sheet. `path` is the sheet instance path (`/<root uuid>/...`) used+/// to pick each symbol's reference for that instance; None takes the Reference field.+pub fn extract(sch: &Schematic, path: Option<&str>) -> SheetModel {+    let mut m = SheetModel::default();+    let lib_symbols = sch.doc.root.child("lib_symbols");+    for n in sch.doc.root.children() {+        match n.head() {+            Some("wire") | Some("bus") => {+                let pts: Vec<Pt> = n.child("pts").map(|p| p.children().iter().filter(|c| c.head() == Some("xy")).filter_map(xy_of).collect()).unwrap_or_default();+                let uuid = n.child_value("uuid").unwrap_or_default();+                for seg in pts.windows(2) {+                    let w = Wire { uuid: uuid.clone(), a: seg[0], b: seg[1] };+                    if n.head() == Some("wire") {+                        m.wires.push(w);+                    } else {+                        m.buses.push(w);+                    }+                }+            }+            Some("junction") => m.junctions.push(Marker { uuid: n.child_value("uuid").unwrap_or_default(), at: at_of(n).0 }),+            Some("no_connect") => m.no_connects.push(Marker { uuid: n.child_value("uuid").unwrap_or_default(), at: at_of(n).0 }),+            Some(h @ ("label" | "global_label" | "hierarchical_label")) => {+                let (at, rotation) = at_of(n);+                let kind = match h {+                    "label" => "local",+                    "global_label" => "global",+                    _ => "hierarchical",+                };+                m.labels.push(Label {+                    uuid: n.child_value("uuid").unwrap_or_default(),+                    text: n.arg(0).unwrap_or_default(),+                    kind: kind.into(),+                    at,+                    rotation,+                    shape: n.child_value("shape"),+                    size: font_size(n),+                });+            }+            Some("text") => {+                let (at, rot) = at_of(n);+                let text = n.arg(0).unwrap_or_default();+                let size = font_size(n);+                let lines: Vec<&str> = text.split('\n').collect();+                let longest = lines.iter().max_by_key(|l| l.chars().count()).copied().unwrap_or("");+                let mut bb = text_bbox(at, longest, size, rot, false, 0.0);+                bb.3 += (lines.len().saturating_sub(1)) as f64 * size * 1.6;+                m.texts.push(TextItem { uuid: n.child_value("uuid").unwrap_or_default(), text, at, kind: "text".into(), owner: None, bbox: bb });+            }+            Some("sheet") => m.sheets.push(sheet_ref(n)),+            Some("symbol") if is_symbol_instance(n) => symbol_items(&mut m, n, lib_symbols, path),+            _ => {}+        }+    }+    m+}++pub fn sheet_ref(n: &Node) -> SheetRef {+    let (at, _) = at_of(n);+    let size = n.child("size").and_then(xy_of).unwrap_or((0.0, 0.0));+    let uuid = n.child_value("uuid").unwrap_or_default();+    let name = symbol_property(n, "Sheetname").or_else(|| symbol_property(n, "Sheet name")).unwrap_or_default();+    let file = symbol_property(n, "Sheetfile").or_else(|| symbol_property(n, "Sheet file")).unwrap_or_default();+    let pins = n+        .children()+        .iter()+        .filter(|c| c.head() == Some("pin"))+        .map(|p| {+            let (pat, rot) = at_of(p);+            SheetPin {+                uuid: p.child_value("uuid").unwrap_or_default(),+                name: p.arg(0).unwrap_or_default(),+                shape: p.arg(1).unwrap_or_else(|| "input".into()),+                at: pat,+                rotation: rot,+                sheet_uuid: uuid.clone(),+                sheet_name: name.clone(),+            }+        })+        .collect();+    let mut pages = Vec::new();+    if let Some(insts) = n.child("instances") {+        for proj in insts.children().iter().filter(|c| c.head() == Some("project")) {+            for p in proj.children().iter().filter(|c| c.head() == Some("path")) {+                pages.push((p.arg(0).unwrap_or_default(), p.child_value("page").unwrap_or_default()));+            }+        }+    }+    SheetRef { uuid, name, file, at, size, pins, pages }+}++fn symbol_items(m: &mut SheetModel, inst: &Node, lib_symbols: Option<&Node>, path: Option<&str>) {+    let reference = instance_reference(inst, path);+    let lib_key = inst.child_value("lib_name").or_else(|| inst.child_value("lib_id")).unwrap_or_default();+    let lib = lib_symbols.and_then(|ls| ls.children().iter().find(|n| n.head() == Some("symbol") && n.arg(0).as_deref() == Some(lib_key.as_str())));+    let unit = inst.child("unit").and_then(|u| u.num(0)).unwrap_or(1.0) as u32;+    let style = inst.child("body_style").or_else(|| inst.child("convert")).and_then(|u| u.num(0)).unwrap_or(1.0) as u32;+    let uuid = inst.child_value("uuid").unwrap_or_default();+    let value = symbol_property(inst, "Value").unwrap_or_default();+    let is_power = lib.map_or(false, |l| l.child("power").is_some()) || reference.starts_with("#PWR") || reference.starts_with("#FLG");+    let xf = Placement::of(inst);+    let mut bbox: Option<BBox> = None;+    if let Some(lib) = lib {+        for sub in unit_subs(lib, &lib_key, unit, style) {+            for g in sub.children() {+                match g.head() {+                    Some("rectangle") => {+                        for k in ["start", "end"] {+                            if let Some(p) = g.child(k).and_then(xy_of) {+                                bbox = bbox_union(bbox, xf.apply(p));+                            }+                        }+                    }+                    Some("polyline") | Some("bezier") => {+                        if let Some(pts) = g.child("pts") {+                            for p in pts.children().iter().filter(|c| c.head() == Some("xy")).filter_map(xy_of) {+                                bbox = bbox_union(bbox, xf.apply(p));+                            }+                        }+                    }+                    Some("circle") => {+                        if let (Some(c), Some(r)) = (g.child("center").and_then(xy_of), g.child("radius").and_then(|r| r.num(0))) {+                            for p in [(c.0 - r, c.1 - r), (c.0 + r, c.1 + r), (c.0 - r, c.1 + r), (c.0 + r, c.1 - r)] {+                                bbox = bbox_union(bbox, xf.apply(p));+                            }+                        }+                    }+                    Some("arc") => {+                        for k in ["start", "mid", "end"] {+                            if let Some(p) = g.child(k).and_then(xy_of) {+                                bbox = bbox_union(bbox, xf.apply(p));+                            }+                        }+                    }+                    Some("pin") => {+                        let (pat, ang) = at_of(g);+                        let len = g.child("length").and_then(|l| l.num(0)).unwrap_or(0.0);+                        let (s, c) = ang.to_radians().sin_cos();+                        let end = (pat.0 + len * c, pat.1 + len * s);+                        let at = xf.apply(pat);+                        bbox = bbox_union(bbox, at);+                        bbox = bbox_union(bbox, xf.apply(end));+                        let number = g.child_value("number").unwrap_or_default();+                        if m.pins.iter().any(|p| p.symbol_uuid == uuid && p.number == number && same(p.at, at)) {+                            continue;+                        }+                        m.pins.push(Pin {+                            reference: reference.clone(),+                            symbol_uuid: uuid.clone(),+                            number,+                            name: g.child_value("name").unwrap_or_default(),+                            electrical: g.arg(0).unwrap_or_else(|| "unspecified".into()),+                            at,+                            hidden: is_hidden(g),+                            power_symbol: is_power,+                            power_net: if is_power { Some(value.clone()) } else { None },+                        });+                    }+                    _ => {}+                }+            }+        }+    } else {+        m.missing_lib.push(reference.clone());+    }+    // Visible fields as text items (overlap checks).+    for prop in inst.children().iter().filter(|c| c.head() == Some("property")) {+        if is_hidden(prop) {+            continue;+        }+        let text = prop.arg(1).unwrap_or_default();+        if text.is_empty() || text == "~" {+            continue;+        }+        let (at, rot) = at_of(prop);+        let size = font_size(prop);+        let justify = prop.child("effects").and_then(|e| e.child("justify"));+        let left = justify.map_or(false, |j| j.children().iter().any(|c| c.raw() == Some("left")));+        let right = justify.map_or(false, |j| j.children().iter().any(|c| c.raw() == Some("right")));+        let bb = if left {+            text_bbox(at, &text, size, rot, false, 0.0)+        } else if right {+            text_bbox(at, &text, size, rot + 180.0, false, 0.0)+        } else {+            text_bbox(at, &text, size, rot, true, 0.0)+        };+        let bb = if left || right { (bb.0, bb.1 + size / 2.0, bb.2, bb.3 + size / 2.0) } else { bb };+        m.texts.push(TextItem { uuid: uuid.clone(), text, at, kind: "field".into(), owner: Some(reference.clone()), bbox: bb });+    }+    m.symbols.push(SymbolMeta {+        uuid,+        reference,+        lib_id: inst.child_value("lib_id").unwrap_or_default(),+        value,+        footprint: symbol_property(inst, "Footprint").unwrap_or_default(),+        description: symbol_property(inst, "Description").unwrap_or_default(),+        unit,+        at: xf.at,+        is_power,+        in_bom: yes(inst, "in_bom", true),+        on_board: yes(inst, "on_board", true),+        dnp: yes(inst, "dnp", false),+        bbox,+    });+}++// ---------------------------------------------------------------------------+// Connectivity+// ---------------------------------------------------------------------------++/// What a connectivity node stands for: an index into the model's list of that kind.+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]+pub enum Item {+    Wire(usize),+    Pin(usize),+    Label(usize),+    SheetPin(usize),+    Junction(usize),+    NoConnect(usize),+}++pub struct Dsu {+    parent: Vec<usize>,+}++impl Dsu {+    pub fn new(n: usize) -> Dsu {+        Dsu { parent: (0..n).collect() }+    }+    pub fn find(&mut self, x: usize) -> usize {+        let mut r = x;+        while self.parent[r] != r {+            r = self.parent[r];+        }+        let mut c = x;+        while self.parent[c] != r {+            let n = self.parent[c];+            self.parent[c] = r;+            c = n;+        }+        r+    }+    pub fn union(&mut self, a: usize, b: usize) {+        let (ra, rb) = (self.find(a), self.find(b));+        if ra != rb {+            self.parent[ra.max(rb)] = ra.min(rb);+        }+    }+    pub fn grow(&mut self) -> usize {+        let n = self.parent.len();+        self.parent.push(n);+        n+    }+}++/// One net on one sheet.+#[derive(Clone, Debug, Default)]+pub struct Group {+    pub items: Vec<Item>,+    /// (kind, text) of every name on the net: kind is local, global, hierarchical, power,+    /// or sheet_pin. Sorted, de-duplicated.+    pub names: Vec<(String, String)>,+}++impl Group {+    pub fn pins<'a>(&'a self, m: &'a SheetModel) -> impl Iterator<Item = &'a Pin> + 'a {+        self.items.iter().filter_map(move |i| if let Item::Pin(k) = i { Some(&m.pins[*k]) } else { None })+    }+    /// Pins of real parts (not power symbols).+    pub fn part_pins<'a>(&'a self, m: &'a SheetModel) -> impl Iterator<Item = &'a Pin> + 'a {+        self.pins(m).filter(|p| !p.power_symbol)+    }+    pub fn has(&self, f: impl Fn(&Item) -> bool) -> bool {+        self.items.iter().any(f)+    }+    /// Can this net continue on another sheet (global label, hierarchical label, sheet pin,+    /// power symbol)?+    pub fn leaves_sheet(&self) -> bool {+        self.names.iter().any(|(k, _)| k != "local")+    }+    /// The name eeschema would pick: power or global first, then hierarchical, then local;+    /// alphabetical within a kind. None for an unnamed net.+    pub fn best_name(&self) -> Option<String> {+        for want in ["power", "global", "hierarchical", "sheet_pin", "local"] {+            if let Some((_, t)) = self.names.iter().find(|(k, _)| k == want) {+                return Some(t.clone());+            }+        }+        None+    }+}++pub struct Connectivity {+    pub groups: Vec<Group>,+    /// Item to group index.+    pub of: HashMap<Item, usize>,+}++impl Connectivity {+    pub fn build(m: &SheetModel) -> Connectivity {+        let mut items: Vec<Item> = Vec::new();+        items.extend((0..m.wires.len()).map(Item::Wire));+        items.extend((0..m.pins.len()).map(Item::Pin));+        items.extend((0..m.labels.len()).map(Item::Label));+        let sheet_pins: Vec<&SheetPin> = m.sheet_pins().collect();+        items.extend((0..sheet_pins.len()).map(Item::SheetPin));+        items.extend((0..m.junctions.len()).map(Item::Junction));+        items.extend((0..m.no_connects.len()).map(Item::NoConnect));+        let idx: HashMap<Item, usize> = items.iter().enumerate().map(|(i, it)| (*it, i)).collect();+        let mut d = Dsu::new(items.len());+        let w = |i: usize| idx[&Item::Wire(i)];++        // wire to wire: shared ends, and an end on another wire's interior (a T)+        for i in 0..m.wires.len() {+            for j in (i + 1)..m.wires.len() {+                let (a, b) = (&m.wires[i], &m.wires[j]);+                let joined = same(a.a, b.a) || same(a.a, b.b) || same(a.b, b.a) || same(a.b, b.b) || on_interior(a.a, b.a, b.b) || on_interior(a.b, b.a, b.b) || on_interior(b.a, a.a, a.b) || on_interior(b.b, a.a, a.b);+                if joined {+                    d.union(w(i), w(j));+                }+            }+        }+        let junction_at = |p: Pt| m.junctions.iter().any(|j| same(j.at, p));+        // a point item joins wires: `anywhere` for labels and junctions, ends (or a dot) for pins+        let attach = |d: &mut Dsu, node: usize, p: Pt, anywhere: bool| {+            for (i, wire) in m.wires.iter().enumerate() {+                let at_end = same(p, wire.a) || same(p, wire.b);+                if at_end || ((anywhere || junction_at(p)) && on_segment(p, wire.a, wire.b)) {+                    d.union(node, w(i));+                }+            }+        };+        for (i, l) in m.labels.iter().enumerate() {+            attach(&mut d, idx[&Item::Label(i)], l.at, true);+        }+        for (i, j) in m.junctions.iter().enumerate() {+            attach(&mut d, idx[&Item::Junction(i)], j.at, true);+        }+        for (i, p) in m.pins.iter().enumerate() {+            attach(&mut d, idx[&Item::Pin(i)], p.at, false);+        }+        for (i, sp) in sheet_pins.iter().enumerate() {+            attach(&mut d, idx[&Item::SheetPin(i)], sp.at, false);+        }+        for (i, nc) in m.no_connects.iter().enumerate() {+            attach(&mut d, idx[&Item::NoConnect(i)], nc.at, false);+        }+        // coincident points: pins, labels, sheet pins and flags sitting on one another+        let mut points: Vec<(Pt, usize)> = Vec::new();+        points.extend(m.pins.iter().enumerate().map(|(i, p)| (p.at, idx[&Item::Pin(i)])));+        points.extend(m.labels.iter().enumerate().map(|(i, l)| (l.at, idx[&Item::Label(i)])));+        points.extend(sheet_pins.iter().enumerate().map(|(i, s)| (s.at, idx[&Item::SheetPin(i)])));+        points.extend(m.no_connects.iter().enumerate().map(|(i, s)| (s.at, idx[&Item::NoConnect(i)])));+        points.extend(m.junctions.iter().enumerate().map(|(i, s)| (s.at, idx[&Item::Junction(i)])));+        let mut by_key: BTreeMap<(i64, i64), Vec<usize>> = BTreeMap::new();+        for (p, n) in &points {+            by_key.entry(((p.0 * 1e3).round() as i64, (p.1 * 1e3).round() as i64)).or_default().push(*n);+        }+        for v in by_key.values() {+            for k in 1..v.len() {+                d.union(v[0], v[k]);+            }+        }+        // names: same-named local labels on the sheet; globals, hierarchical labels and power+        // symbols by name; hidden power-input pins of parts join the global net of their name+        let mut by_name: BTreeMap<(String, String), usize> = BTreeMap::new();+        let mut name_join = |d: &mut Dsu, scope: &str, text: &str, node: usize| {+            let k = (scope.to_string(), text.to_string());+            match by_name.get(&k) {+                Some(&first) => d.union(first, node),+                None => {+                    by_name.insert(k, node);+                }+            }+        };+        for (i, l) in m.labels.iter().enumerate() {+            let scope = if l.kind == "global" { "global" } else { l.kind.as_str() };+            name_join(&mut d, scope, &l.text, idx[&Item::Label(i)]);+        }+        for (i, p) in m.pins.iter().enumerate() {+            if let Some(n) = &p.power_net {+                name_join(&mut d, "global", n, idx[&Item::Pin(i)]);+            } else if p.hidden && p.electrical == "power_in" && !p.name.is_empty() {+                name_join(&mut d, "global", &p.name, idx[&Item::Pin(i)]);+            }+        }++        let mut root_to_group: HashMap<usize, usize> = HashMap::new();+        let mut groups: Vec<Group> = Vec::new();+        let mut of = HashMap::new();+        for (n, it) in items.iter().enumerate() {+            let r = d.find(n);+            let g = *root_to_group.entry(r).or_insert_with(|| {+                groups.push(Group::default());+                groups.len() - 1+            });+            groups[g].items.push(*it);+            of.insert(*it, g);+        }+        for g in groups.iter_mut() {+            let mut names: BTreeSet<(String, String)> = BTreeSet::new();+            for it in &g.items {+                match it {+                    Item::Label(i) => {+                        names.insert((m.labels[*i].kind.clone(), m.labels[*i].text.clone()));+                    }+                    Item::Pin(i) => {+                        if let Some(n) = &m.pins[*i].power_net {+                            names.insert(("power".into(), n.clone()));+                        } else if m.pins[*i].hidden && m.pins[*i].electrical == "power_in" && !m.pins[*i].name.is_empty() {+                            names.insert(("power".into(), m.pins[*i].name.clone()));+                        }+                    }+                    Item::SheetPin(i) => {+                        names.insert(("sheet_pin".into(), sheet_pins[*i].name.clone()));+                    }+                    _ => {}+                }+            }+            g.names = names.into_iter().collect();+        }+        Connectivity { groups, of }+    }++    /// The net name: the best label or power name, else `Net-(<ref>-Pad<n>)` from the first+    /// part pin in reference order, else `unconnected`.+    pub fn net_name(&self, m: &SheetModel, g: usize) -> String {+        let grp = &self.groups[g];+        if let Some(n) = grp.best_name() {+            return n;+        }+        let mut pins: Vec<&Pin> = grp.part_pins(m).collect();+        pins.sort_by(|a, b| crate::sch_model::ref_order(&a.reference).cmp(&ref_order(&b.reference)).then(a.number.cmp(&b.number)));+        match pins.first() {+            Some(p) => format!("Net-({}-Pad{})", p.reference, p.number),+            None => "unconnected".into(),+        }+    }+}++/// Sort key for references: prefix, then number (R2 before R10).+pub fn ref_order(r: &str) -> (String, u64, String) {+    let head: String = r.chars().take_while(|c| !c.is_ascii_digit()).collect();+    let digits: String = r[head.len()..].chars().take_while(|c| c.is_ascii_digit()).collect();+    let rest = r[head.len() + digits.len()..].to_string();+    (head, digits.parse().unwrap_or(0), rest)+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn segment_tests() {+        assert!(on_segment((5.0, 0.0), (0.0, 0.0), (10.0, 0.0)));+        assert!(on_interior((5.0, 0.0), (0.0, 0.0), (10.0, 0.0)));+        assert!(!on_interior((10.0, 0.0), (0.0, 0.0), (10.0, 0.0)));+        assert!(!on_segment((11.0, 0.0), (0.0, 0.0), (10.0, 0.0)));+        assert!(on_segment((5.0, 5.0), (0.0, 0.0), (10.0, 10.0)));+        assert!(!on_segment((5.0, 5.1), (0.0, 0.0), (10.0, 10.0)));+    }++    #[test]+    fn overlap_needs_area() {+        assert!(overlap((0.0, 0.0, 10.0, 10.0), (10.0, 0.0, 20.0, 10.0), 0.1).is_none());+        assert!(overlap((0.0, 0.0, 10.0, 10.0), (5.0, 5.0, 20.0, 20.0), 0.1).is_some());+    }++    #[test]+    fn ref_order_is_natural() {+        let mut v = vec!["R10", "R2", "C1", "R1"];+        v.sort_by_key(|r| ref_order(r));+        assert_eq!(v, vec!["C1", "R1", "R2", "R10"]);+    }+}
rust/crates/kicad-core/src/sch_render.rsadded+505
@@ -0,0 +1,505 @@+//! Schematic to PNG and visual baselines.+//!+//! `kicad-cli sch export svg --output <dir> <root>` writes one SVG per sheet (the root as+//! `<stem>.svg`, sub-sheets as `<stem>-<sheet path>.svg`). The SVG is rasterised in-process+//! with resvg (pure Rust: usvg parses, tiny-skia paints), so no Inkscape, cairo or browser is+//! needed on the desktop. KiCad plots text as strokes (the `<text>` elements it adds are+//! invisible, for search), so resvg is built without its font stack.+//!+//! A baseline is a PNG plus a small JSON (width, source hash, renderer) under+//! `<project>/.adom/baselines/`. Compare renders at the baseline's width and reports the share+//! of pixels that changed and the bounding box of the change, in pixels and in sheet mm.+//!+//! The kicad-cli call goes through `render::Runner`, so the argv and the file pick are unit+//! tested with canned output and no KiCad.++use std::path::{Path, PathBuf};+use std::time::Duration;++use resvg::tiny_skia::{self, Pixmap};+use resvg::usvg;+use serde_json::{json, Value};++use crate::render::Runner;++#[derive(Clone, Debug)]+pub struct SvgOpts {+    pub drawing_sheet: bool,+    pub black_and_white: bool,+    pub theme: Option<String>,+}++impl Default for SvgOpts {+    fn default() -> Self {+        SvgOpts { drawing_sheet: true, black_and_white: false, theme: None }+    }+}++fn norm(p: &Path) -> String {+    p.to_string_lossy().replace('\\', "/")+}++/// The kicad-cli argv (no program name): `sch export svg --output <dir> [...] <file>`.+pub fn svg_argv(sch: &Path, out_dir: &Path, o: &SvgOpts) -> Vec<String> {+    let mut v: Vec<String> = vec!["sch".into(), "export".into(), "svg".into(), "--output".into(), norm(out_dir)];+    if !o.drawing_sheet {+        v.push("--exclude-drawing-sheet".into());+    }+    if o.black_and_white {+        v.push("--black-and-white".into());+    }+    if let Some(t) = &o.theme {+        v.push("--theme".into());+        v.push(t.clone());+    }+    v.push(norm(sch));+    v+}++/// Run the export into `out_dir` (emptied of old SVGs first) and list what it wrote.+pub fn export_svgs(run: Runner, sch: &Path, out_dir: &Path, o: &SvgOpts, timeout: Duration) -> Result<Vec<PathBuf>, String> {+    std::fs::create_dir_all(out_dir).map_err(|e| format!("cannot create {}: {e}", out_dir.display()))?;+    for old in list_svgs(out_dir) {+        let _ = std::fs::remove_file(old);+    }+    let r = run(&svg_argv(sch, out_dir, o), timeout);+    let files = list_svgs(out_dir);+    if r.timed_out {+        return Err(format!("kicad-cli sch export svg timed out after {} s", timeout.as_secs()));+    }+    if files.is_empty() {+        let tail: String = r.stderr.trim().chars().rev().take(400).collect::<Vec<_>>().into_iter().rev().collect();+        return Err(format!("kicad-cli sch export svg wrote no SVG (exit {}): {}", r.exit_code, if tail.is_empty() { r.stdout.trim().to_string() } else { tail }));+    }+    Ok(files)+}++pub fn list_svgs(dir: &Path) -> Vec<PathBuf> {+    let mut v: Vec<PathBuf> = std::fs::read_dir(dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().and_then(|x| x.to_str()).map_or(false, |x| x.eq_ignore_ascii_case("svg"))).collect();+    v.sort();+    v+}++fn stem(p: &Path) -> String {+    p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default()+}++fn squash(s: &str) -> String {+    s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()+}++/// Pick the SVG for one sheet. `sheet` is None for the root, else (sheet name, human path like+/// `/Power/Filter/`). Exact `<stem>-<path>` first, then a name suffix, then a substring.+pub fn pick_svg(files: &[PathBuf], root_stem: &str, sheet: Option<(&str, &str)>) -> Option<PathBuf> {+    match sheet {+        None => files.iter().find(|f| stem(f) == root_stem).cloned().or_else(|| files.iter().min_by_key(|f| stem(f).len()).cloned()),+        Some((name, human)) => {+            let path_part = human.trim_matches('/').replace('/', "-");+            let exact = squash(&format!("{root_stem}-{path_part}"));+            if let Some(f) = files.iter().find(|f| squash(&stem(f)) == exact) {+                return Some(f.clone());+            }+            let n = squash(name);+            files+                .iter()+                .filter(|f| stem(f) != root_stem)+                .find(|f| squash(&stem(f)).ends_with(&n))+                .or_else(|| files.iter().filter(|f| stem(f) != root_stem).find(|f| squash(&stem(f)).contains(&n)))+                .cloned()+        }+    }+}++pub struct Raster {+    pub pixmap: Pixmap,+    pub width_mm: f64,+    pub height_mm: f64,+}++/// Rasterise SVG bytes to `width` pixels wide (height follows the aspect), on white.+pub fn rasterize(svg: &[u8], width: u32) -> Result<Raster, String> {+    if !(16..=16384).contains(&width) {+        return Err(format!("width {width} is out of range (16 to 16384 px)"));+    }+    let tree = usvg::Tree::from_data(svg, &usvg::Options::default()).map_err(|e| format!("cannot parse the SVG: {e}"))?;+    let size = tree.size();+    let (w_px, h_px) = (size.width() as f64, size.height() as f64);+    if w_px <= 0.0 || h_px <= 0.0 {+        return Err("the SVG has no size".into());+    }+    let scale = width as f64 / w_px;+    let height = (h_px * scale).round().max(1.0) as u32;+    if (width as u64) * (height as u64) > 200_000_000 {+        return Err(format!("{width} x {height} px is too large; pass a smaller width"));+    }+    let mut pixmap = Pixmap::new(width, height).ok_or("cannot allocate the image")?;+    pixmap.fill(tiny_skia::Color::WHITE);+    resvg::render(&tree, tiny_skia::Transform::from_scale(scale as f32, scale as f32), &mut pixmap.as_mut());+    // usvg resolves mm to px at 96 dpi+    Ok(Raster { pixmap, width_mm: w_px * 25.4 / 96.0, height_mm: h_px * 25.4 / 96.0 })+}++pub struct Diff {+    pub changed: u64,+    pub total: u64,+    pub percent: f64,+    /// (x0, y0, x1, y1) inclusive, pixels.+    pub bbox: Option<(u32, u32, u32, u32)>,+    /// The current image with changed pixels painted red and the rest faded.+    pub image: Pixmap,+}++/// Compare two same-size images: a pixel changed when any channel moved more than `tol`.+pub fn diff(base: &Pixmap, cur: &Pixmap, tol: u8) -> Result<Diff, String> {+    if base.width() != cur.width() || base.height() != cur.height() {+        return Err(format!("sizes differ: baseline {}x{}, now {}x{}", base.width(), base.height(), cur.width(), cur.height()));+    }+    let (w, h) = (cur.width(), cur.height());+    let mut out = Pixmap::new(w, h).ok_or("cannot allocate the diff image")?;+    let (a, b) = (base.data(), cur.data());+    let o = out.data_mut();+    let mut changed = 0u64;+    let mut bb: Option<(u32, u32, u32, u32)> = None;+    for i in 0..(w as usize * h as usize) {+        let k = i * 4;+        let moved = (0..3).any(|c| a[k + c].abs_diff(b[k + c]) > tol);+        if moved {+            changed += 1;+            let (x, y) = ((i % w as usize) as u32, (i / w as usize) as u32);+            bb = Some(match bb {+                None => (x, y, x, y),+                Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x), y1.max(y)),+            });+            o[k] = 230;+            o[k + 1] = 0;+            o[k + 2] = 0;+            o[k + 3] = 255;+        } else {+            // faded copy of the current image for context+            for c in 0..3 {+                o[k + c] = 255 - (255 - b[k + c]) / 4;+            }+            o[k + 3] = 255;+        }+    }+    let total = w as u64 * h as u64;+    Ok(Diff { changed, total, percent: if total == 0 { 0.0 } else { changed as f64 * 100.0 / total as f64 }, bbox: bb, image: out })+}++/// FNV-1a over the bytes of every file, in order: the baseline's source hash.+pub fn source_hash(files: &[PathBuf]) -> String {+    let mut h: u64 = 0xcbf29ce484222325;+    for f in files {+        for b in std::fs::read(f).unwrap_or_default() {+            h ^= b as u64;+            h = h.wrapping_mul(0x100000001b3);+        }+    }+    format!("{h:016x}")+}++/// A file-name-safe key for a sheet: `hier`, `hier-Power-Filter`.+pub fn baseline_key(root_stem: &str, human: &str) -> String {+    let p = human.trim_matches('/');+    let raw = if p.is_empty() { root_stem.to_string() } else { format!("{root_stem}-{}", p.replace('/', "-")) };+    raw.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }).collect()+}++pub fn baseline_dir(project_dir: &Path) -> PathBuf {+    project_dir.join(".adom").join("baselines")+}++/// Write a PNG atomically.+pub fn save_png(p: &Pixmap, path: &Path) -> Result<(), String> {+    let bytes = p.encode_png().map_err(|e| format!("cannot encode PNG: {e}"))?;+    if let Some(d) = path.parent() {+        std::fs::create_dir_all(d).map_err(|e| format!("cannot create {}: {e}", d.display()))?;+    }+    crate::libraries::write_atomic(path, &bytes)+}++pub fn load_png(path: &Path) -> Result<Pixmap, String> {+    Pixmap::load_png(path).map_err(|e| format!("cannot read {}: {e}", path.display()))+}++/// Pixel box to sheet millimetres.+pub fn bbox_mm(b: (u32, u32, u32, u32), px_per_mm: f64) -> Value {+    let f = |v: u32| ((v as f64 / px_per_mm) * 100.0).round() / 100.0;+    json!([f(b.0), f(b.1), f(b.2 + 1), f(b.3 + 1)])+}++/// What `render_sheet` should do with the image.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum BaselineMode {+    None,+    Set,+    Compare,+}++pub struct RenderJob<'a> {+    /// The root sheet kicad-cli plots (sub-sheets are plotted from it).+    pub root: &'a Path,+    /// None for the root page, else (sheet name, human path `/Power/`).+    pub sheet: Option<(&'a str, &'a str)>,+    pub width: u32,+    pub out_dir: &'a Path,+    pub project_dir: &'a Path,+    pub svg: SvgOpts,+    pub mode: BaselineMode,+    /// Allowed drift in percent of pixels for a compare to pass.+    pub threshold: f64,+    /// kicad-cli version (or path) recorded with a baseline.+    pub renderer: String,+    /// Schematic files whose bytes make the source hash.+    pub sources: Vec<PathBuf>,+}++/// A failure with the reply's errorCode and hint.+#[derive(Debug)]+pub struct RenderError {+    pub code: &'static str,+    pub error: String,+    pub hint: String,+}++fn rerr(code: &'static str, error: impl Into<String>, hint: impl Into<String>) -> RenderError {+    RenderError { code, error: error.into(), hint: hint.into() }+}++/// Export, pick the sheet's SVG, rasterise to PNG, and set or compare the baseline.+pub fn render_sheet(run: Runner, job: &RenderJob) -> Result<Value, RenderError> {+    let svg_dir = job.out_dir.join("svg");+    let files = export_svgs(run, job.root, &svg_dir, &job.svg, Duration::from_secs(240)).map_err(|e| rerr("cli_failed", e, "kicad-cli could not plot the schematic: run kicad_run_erc or kicad_lint_schematic on the root sheet for the reason, and check the file opens in KiCad."))?;+    let root_stem = stem(job.root);+    let svg = pick_svg(&files, &root_stem, job.sheet).ok_or_else(|| {+        rerr(+            "sheet_not_found",+            format!("no SVG for sheet {} among {}", job.sheet.map(|s| s.0).unwrap_or("root"), files.iter().map(|f| stem(f)).collect::<Vec<_>>().join(", ")),+            "kicad_sch_hierarchy lists the sheet names; pass sheet as one of them, or omit it for the root page.",+        )+    })?;+    let human = job.sheet.map(|s| s.1).unwrap_or("/");+    let key = baseline_key(&root_stem, human);+    let base_png = baseline_dir(job.project_dir).join(format!("{key}.png"));+    let base_json = baseline_dir(job.project_dir).join(format!("{key}.json"));+    let meta: Option<Value> = std::fs::read_to_string(&base_json).ok().and_then(|t| serde_json::from_str(&t).ok());+    let width = if job.mode == BaselineMode::Compare { meta.as_ref().and_then(|m| m["width"].as_u64()).map(|w| w as u32).unwrap_or(job.width) } else { job.width };+    let bytes = std::fs::read(&svg).map_err(|e| rerr("read_failed", format!("cannot read {}: {e}", svg.display()), "The export folder must be readable."))?;+    let raster = rasterize(&bytes, width).map_err(|e| rerr("render_failed", e, "The SVG kicad-cli wrote could not be rasterised; the svg path in the reply is still usable."))?;+    let png = job.out_dir.join(format!("{key}.png"));+    save_png(&raster.pixmap, &png).map_err(|e| rerr("write_failed", e, "Pass an outputDir the desktop user can write."))?;+    let hash = source_hash(&job.sources);+    let px_per_mm = raster.pixmap.width() as f64 / raster.width_mm;+    let mut v = json!({+        "png": norm(&png),+        "svg": norm(&svg),+        "svgFiles": files.iter().map(|f| norm(f)).collect::<Vec<_>>(),+        "sheet": job.sheet.map(|s| s.0).unwrap_or("Root"),+        "sheetPath": human,+        "width": raster.pixmap.width(),+        "height": raster.pixmap.height(),+        "pageMm": [((raster.width_mm * 100.0).round() / 100.0), ((raster.height_mm * 100.0).round() / 100.0)],+        "sourceHash": hash,+    });+    match job.mode {+        BaselineMode::None => {}+        BaselineMode::Set => {+            save_png(&raster.pixmap, &base_png).map_err(|e| rerr("write_failed", e, "The project folder must be writable for baselines."))?;+            let m = json!({"sheet": v["sheet"], "sheetPath": human, "width": raster.pixmap.width(), "height": raster.pixmap.height(), "sourceHash": hash, "renderer": job.renderer, "rasterizer": "resvg 0.45", "createdAt": now_iso()});+            crate::libraries::write_atomic(&base_json, serde_json::to_string_pretty(&m).unwrap_or_default().as_bytes()).map_err(|e| rerr("write_failed", e, "The project folder must be writable for baselines."))?;+            v["baseline"] = json!({"set": true, "png": norm(&base_png), "meta": norm(&base_json), "replaced": meta.is_some()});+        }+        BaselineMode::Compare => {+            let Some(meta) = meta else {+                v["baseline"] = json!({"stored": false, "png": norm(&base_png)});+                return Err(RenderError { code: "no_baseline", error: format!("no baseline stored for {key} (looked for {})", norm(&base_png)), hint: "Set one first: kicad_render_schematic {baseline:\"set\"} on the same sheet, then compare after the edit.".into() });+            };+            let base = load_png(&base_png).map_err(|e| rerr("no_baseline", e, "The baseline PNG is missing or damaged: set it again with baseline:\"set\"."))?;+            let renderer_changed = meta["renderer"].as_str().map_or(false, |r| r != job.renderer);+            let source_changed = meta["sourceHash"].as_str().map_or(true, |h| h != hash);+            match diff(&base, &raster.pixmap, 24) {+                Ok(d) => {+                    let diff_png = job.out_dir.join(format!("{key}-diff.png"));+                    let _ = save_png(&d.image, &diff_png);+                    let pass = d.percent <= job.threshold;+                    v["baseline"] = json!({+                        "stored": true,+                        "png": norm(&base_png),+                        "createdAt": meta["createdAt"],+                        "driftPercent": (d.percent * 1000.0).round() / 1000.0,+                        "changedPixels": d.changed,+                        "totalPixels": d.total,+                        "threshold": job.threshold,+                        "pass": pass,+                        "changedRegionPx": d.bbox.map(|b| json!([b.0, b.1, b.2, b.3])),+                        "changedRegionMm": d.bbox.map(|b| bbox_mm(b, px_per_mm)),+                        "diffPng": norm(&diff_png),+                        "sourceChanged": source_changed,+                        "rendererChanged": renderer_changed,+                        "baselineRenderer": meta["renderer"],+                    });+                }+                Err(e) => {+                    v["baseline"] = json!({"stored": true, "png": norm(&base_png), "sizeChanged": true, "detail": e, "driftPercent": 100.0, "pass": false, "threshold": job.threshold, "sourceChanged": source_changed, "rendererChanged": renderer_changed});+                }+            }+        }+    }+    Ok(v)+}++fn now_iso() -> String {+    let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);+    let days = (secs / 86400) as i64;+    let rem = secs % 86400;+    // civil from days (Howard Hinnant)+    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 y = yoe + era * 400;+    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);+    let mp = (5 * doy + 2) / 153;+    let d = doy - (153 * mp + 2) / 5 + 1;+    let m = if mp < 10 { mp + 3 } else { mp - 9 };+    let y = if m <= 2 { y + 1 } else { y };+    format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, (rem % 3600) / 60, rem % 60)+}++#[cfg(test)]+mod tests {+    use super::*;+    use crate::cli::CliResult;++    fn fixture_svg() -> Vec<u8> {+        std::fs::read(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/sch_hier/mcu-kicad10.svg")).unwrap()+    }++    #[test]+    fn argv_is_kicad_cli_shape() {+        let a = svg_argv(Path::new("C:\\p\\x.kicad_sch"), Path::new("C:/out"), &SvgOpts { drawing_sheet: false, black_and_white: true, theme: Some("KiCad Classic".into()) });+        assert_eq!(a, vec!["sch", "export", "svg", "--output", "C:/out", "--exclude-drawing-sheet", "--black-and-white", "--theme", "KiCad Classic", "C:/p/x.kicad_sch"]);+        assert_eq!(svg_argv(Path::new("/p/x.kicad_sch"), Path::new("/o"), &SvgOpts::default()).len(), 6);+    }++    #[test]+    fn pick_by_sheet() {+        let files: Vec<PathBuf> = ["hier.svg", "hier-Power.svg", "hier-Power-Filter.svg", "hier-MCU.svg"].iter().map(PathBuf::from).collect();+        assert_eq!(pick_svg(&files, "hier", None).unwrap(), PathBuf::from("hier.svg"));+        assert_eq!(pick_svg(&files, "hier", Some(("Filter", "/Power/Filter/"))).unwrap(), PathBuf::from("hier-Power-Filter.svg"));+        assert_eq!(pick_svg(&files, "hier", Some(("Power", "/Power/"))).unwrap(), PathBuf::from("hier-Power.svg"));+        assert_eq!(pick_svg(&files, "hier", Some(("mcu", "/x/"))).unwrap(), PathBuf::from("hier-MCU.svg"));+        assert!(pick_svg(&files, "hier", Some(("Nope", "/Nope/"))).is_none());+    }++    #[test]+    fn export_through_a_canned_runner() {+        let dir = std::env::temp_dir().join(format!("sch-render-{}-{}", std::process::id(), crate::schematic::new_uuid()));+        let svg = fixture_svg();+        let seen = std::sync::Mutex::new(Vec::<String>::new());+        let run = |argv: &[String], _t: Duration| -> CliResult {+            *seen.lock().unwrap() = argv.to_vec();+            let out = PathBuf::from(&argv[4]);+            std::fs::write(out.join("hier.svg"), &svg).unwrap();+            std::fs::write(out.join("hier-Power.svg"), &svg).unwrap();+            CliResult { exit_code: 0, stdout: "Plotted to 'hier.svg'.\nDone.\n".into(), stderr: String::new(), elapsed_ms: 5, timed_out: false }+        };+        let files = export_svgs(&run, Path::new("/p/hier.kicad_sch"), &dir, &SvgOpts::default(), Duration::from_secs(5)).unwrap();+        assert_eq!(files.len(), 2);+        assert_eq!(seen.lock().unwrap()[..3], ["sch", "export", "svg"]);+        // a failing kicad-cli: no SVG, stderr surfaces+        let bad = |_: &[String], _: Duration| CliResult { exit_code: 3, stdout: String::new(), stderr: "Failed to load schematic".into(), elapsed_ms: 1, timed_out: false };+        let e = export_svgs(&bad, Path::new("/p/hier.kicad_sch"), &dir, &SvgOpts::default(), Duration::from_secs(5)).unwrap_err();+        assert!(e.contains("exit 3") && e.contains("Failed to load"), "{e}");+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn rasterize_and_diff_a_kicad_svg() {+        let svg = fixture_svg();+        let r = rasterize(&svg, 1200).unwrap();+        assert_eq!(r.pixmap.width(), 1200);+        assert!((r.width_mm - 297.0).abs() < 0.1, "{}", r.width_mm);+        let h = r.pixmap.height();+        assert!((h as f64 - 1200.0 * 210.0 / 297.0).abs() < 2.0);+        // something got drawn: not all white+        if let Ok(dir) = std::env::var("SCH_RENDER_DUMP") {+            save_png(&r.pixmap, &Path::new(&dir).join("mcu.png")).unwrap();+        }+        let dark = r.pixmap.data().chunks(4).filter(|p| p[0].min(p[1]).min(p[2]) < 180).count();+        assert!(dark > 1000, "only {dark} dark pixels");+        let same = diff(&r.pixmap, &r.pixmap, 16).unwrap();+        assert_eq!(same.changed, 0);+        assert!(same.bbox.is_none());+        // move a wire: the drift lands where the wire is+        let text = String::from_utf8(svg.clone()).unwrap();+        let edited = text.replacen("</svg>", "<rect x=\"100\" y=\"100\" width=\"20\" height=\"10\" fill=\"#000000\"/></svg>", 1);+        let r2 = rasterize(edited.as_bytes(), 1200).unwrap();+        let d = diff(&r.pixmap, &r2.pixmap, 16).unwrap();+        assert!(d.percent > 0.25 && d.percent < 0.5, "{}", d.percent); // 20 x 10 mm of 297 x 210+        let (x0, y0, x1, y1) = d.bbox.unwrap();+        let ppm = 1200.0 / r.width_mm;+        let mm = bbox_mm((x0, y0, x1, y1), ppm);+        assert!((mm[0].as_f64().unwrap() - 100.0).abs() < 0.5 && (mm[2].as_f64().unwrap() - 120.0).abs() < 0.5, "{mm}");+        assert!(rasterize(b"not svg", 100).is_err());+        assert!(diff(&r.pixmap, &rasterize(&svg, 600).unwrap().pixmap, 16).is_err());+    }++    #[test]+    fn baseline_set_then_compare() {+        let dir = std::env::temp_dir().join(format!("sch-baseline-{}-{}", std::process::id(), crate::schematic::new_uuid()));+        std::fs::create_dir_all(&dir).unwrap();+        let root = dir.join("mcu.kicad_sch");+        std::fs::write(&root, "(kicad_sch (version 20260306))").unwrap();+        let svg = std::sync::Mutex::new(fixture_svg());+        let run = |argv: &[String], _t: Duration| -> CliResult {+            std::fs::write(PathBuf::from(&argv[4]).join("mcu.svg"), &*svg.lock().unwrap()).unwrap();+            CliResult { exit_code: 0, stdout: String::new(), stderr: String::new(), elapsed_ms: 1, timed_out: false }+        };+        let out = dir.join("render");+        let mut job = RenderJob { root: &root, sheet: None, width: 600, out_dir: &out, project_dir: &dir, svg: SvgOpts::default(), mode: BaselineMode::Compare, threshold: 0.1, renderer: "kicad-cli 10.0.2".into(), sources: vec![root.clone()] };+        let e = render_sheet(&run, &job).unwrap_err();+        assert_eq!(e.code, "no_baseline");+        job.mode = BaselineMode::Set;+        let v = render_sheet(&run, &job).unwrap();+        assert_eq!(v["baseline"]["set"], true);+        assert!(dir.join(".adom/baselines/mcu.png").is_file() && dir.join(".adom/baselines/mcu.json").is_file());+        // unchanged: zero drift, compare renders at the baseline's width even when asked otherwise+        job.mode = BaselineMode::Compare;+        job.width = 900;+        let v = render_sheet(&run, &job).unwrap();+        assert_eq!(v["width"], 600);+        assert_eq!(v["baseline"]["driftPercent"], 0.0);+        assert_eq!(v["baseline"]["pass"], true);+        assert_eq!(v["baseline"]["sourceChanged"], false);+        // an edit: a block appears at x 200..220 mm+        let edited = String::from_utf8(fixture_svg()).unwrap().replacen("</svg>", "<rect x=\"200\" y=\"150\" width=\"20\" height=\"20\" fill=\"#000000\"/></svg>", 1);+        *svg.lock().unwrap() = edited.into_bytes();+        std::fs::write(&root, "(kicad_sch (version 20260306) (uuid \"x\"))").unwrap();+        let v = render_sheet(&run, &job).unwrap();+        let b = &v["baseline"];+        assert_eq!(b["pass"], false, "{b}");+        assert_eq!(b["sourceChanged"], true);+        let mm = b["changedRegionMm"].as_array().unwrap();+        assert!((mm[0].as_f64().unwrap() - 200.0).abs() < 1.0 && (mm[3].as_f64().unwrap() - 170.0).abs() < 1.0, "{mm:?}");+        assert!(PathBuf::from(b["diffPng"].as_str().unwrap()).is_file());+        // another renderer is flagged, never silently trusted+        job.renderer = "kicad-cli 10.0.3".into();+        assert_eq!(render_sheet(&run, &job).unwrap()["baseline"]["rendererChanged"], true);+        let _ = std::fs::remove_dir_all(&dir);+    }++    #[test]+    fn iso_dates_look_right() {+        let s = now_iso();+        assert_eq!(s.len(), 20);+        assert!(s.starts_with("20") && s.ends_with('Z'));+    }++    #[test]+    fn keys_are_file_safe() {+        assert_eq!(baseline_key("hier", "/"), "hier");+        assert_eq!(baseline_key("hier", "/Power/Filter/"), "hier-Power-Filter");+        assert_eq!(baseline_key("hier", "/A B:C/"), "hier-A_B_C");+    }+}
rust/crates/kicad-core/src/sch_review.rsadded+688
@@ -0,0 +1,688 @@+//! Schematic design review from the netlist: decoupling on IC power pins, I2C pull-ups,+//! floating reset and enable pins, LED series resistors, regulator input and output caps,+//! bulk capacitance per rail, and annotation (unannotated or duplicate references).+//!+//! The checks read a `ReviewModel`: parts with their pins (number, name, electrical type,+//! net) and the nets. It is built either from kicad-cli's netlist (`from_netlist`, the+//! authoritative compile, when kicad-cli is installed) or from the files themselves+//! (`from_hierarchy`, this bridge's own connectivity across the sheet tree: global labels+//! and power symbols by name, hierarchical labels to their sheet pins).++use std::collections::{BTreeMap, BTreeSet, HashMap};++use serde::Serialize;+use serde_json::{json, Value};++use crate::netlist::Netlist;+use crate::sch_hier::Hierarchy;+use crate::sch_model::{self, ref_order, Connectivity, Dsu, Item};++#[derive(Clone, Debug, Serialize)]+pub struct RPin {+    pub number: String,+    pub name: String,+    pub electrical: String,+    pub net: String,+}++#[derive(Clone, Debug, Serialize)]+pub struct RPart {+    pub reference: String,+    pub value: String,+    #[serde(rename = "libId")]+    pub lib_id: String,+    pub footprint: String,+    pub description: String,+    pub pins: Vec<RPin>,+}++#[derive(Clone, Debug, Default)]+pub struct ReviewModel {+    pub parts: Vec<RPart>,+    /// Net name to (part index, pin index).+    pub nets: BTreeMap<String, Vec<(usize, usize)>>,+    /// Net names known to come from a power symbol or a global power flag.+    pub power_named: BTreeSet<String>,+    /// Every placed reference occurrence: (reference, unit, where) for annotation checks.+    pub occurrences: Vec<(String, u32, String)>,+    pub source: &'static str,+    pub sheets: usize,+    pub notes: Vec<String>,+}++impl ReviewModel {+    fn index_nets(&mut self) {+        self.nets.clear();+        for (pi, p) in self.parts.iter().enumerate() {+            for (k, pin) in p.pins.iter().enumerate() {+                self.nets.entry(pin.net.clone()).or_default().push((pi, k));+            }+        }+    }+}++/// kicad-cli's kicadxml netlist: authoritative (hierarchy, buses, every rule eeschema has).+pub fn from_netlist(n: &Netlist) -> ReviewModel {+    let mut parts: Vec<RPart> = n+        .components+        .iter()+        .map(|c| RPart { reference: c.reference.clone(), value: c.value.clone(), lib_id: c.libsource.clone(), footprint: c.footprint.clone(), description: c.description.clone(), pins: vec![] })+        .collect();+    let index: HashMap<String, usize> = parts.iter().enumerate().map(|(i, p)| (p.reference.clone(), i)).collect();+    for net in &n.nets {+        for node in &net.nodes {+            if let Some(&i) = index.get(&node.reference) {+                let name = node.pinfunction.strip_suffix(&format!("_{}", node.pin)).unwrap_or(&node.pinfunction).to_string();+                parts[i].pins.push(RPin { number: node.pin.clone(), name, electrical: node.pintype.clone(), net: net.name.clone() });+            }+        }+    }+    // the netlist folds parts sharing a reference into one: annotation reads the files+    // (`occurrences`) when the caller can load them+    let occurrences = n.components.iter().map(|c| (c.reference.clone(), 1, String::new())).collect();+    let notes = vec!["Compiled by kicad-cli. Duplicate references are folded into one part by the netlist, so the annotation check reads the sheet files.".to_string()];+    let mut m = ReviewModel { parts, occurrences, source: "netlist", sheets: 0, notes, ..Default::default() };+    m.index_nets();+    m+}++/// Every placed (non-power) symbol in the design, per sheet instance: (reference, unit, where).+pub fn occurrences(h: &Hierarchy) -> Vec<(String, u32, String)> {+    let mut out = Vec::new();+    for (i, inst) in h.instances.iter().enumerate() {+        let Some(s) = h.sch(i) else { continue };+        for sym in sch_model::extract(s, Some(&inst.path)).symbols.into_iter().filter(|x| !x.is_power) {+            out.push((sym.reference, sym.unit, format!("{} {}", inst.human, sym.uuid)));+        }+    }+    out+}++/// The bridge's own compile of the sheet tree.+pub fn from_hierarchy(h: &Hierarchy) -> ReviewModel {+    struct Sheet {+        model: sch_model::SheetModel,+        conn: Connectivity,+        offset: usize,+    }+    let mut sheets: Vec<Option<Sheet>> = Vec::new();+    let mut total = 0;+    for (i, inst) in h.instances.iter().enumerate() {+        match h.sch(i) {+            Some(s) => {+                let model = sch_model::extract(s, Some(&inst.path));+                let conn = Connectivity::build(&model);+                let n = conn.groups.len();+                sheets.push(Some(Sheet { model, conn, offset: total }));+                total += n;+            }+            None => sheets.push(None),+        }+    }+    let mut d = Dsu::new(total);+    // global and power names join across sheets+    let mut global: HashMap<String, usize> = HashMap::new();+    let mut power_named = BTreeSet::new();+    for s in sheets.iter().flatten() {+        for (g, grp) in s.conn.groups.iter().enumerate() {+            for (k, t) in &grp.names {+                if k == "global" || k == "power" {+                    if k == "power" {+                        power_named.insert(t.clone());+                    }+                    let id = s.offset + g;+                    match global.get(t) {+                        Some(&first) => d.union(first, id),+                        None => {+                            global.insert(t.clone(), id);+                        }+                    }+                }+            }+        }+    }+    // hierarchical labels in a child join the parent's sheet pin of the same name+    for (i, inst) in h.instances.iter().enumerate() {+        let (Some(child), Some(parent), Some(suuid)) = (sheets[i].as_ref(), inst.parent, inst.sheet_uuid.as_ref()) else { continue };+        let Some(par) = sheets[parent].as_ref() else { continue };+        let sps: Vec<&sch_model::SheetPin> = par.model.sheet_pins().collect();+        for (li, l) in child.model.labels.iter().enumerate() {+            if l.kind != "hierarchical" {+                continue;+            }+            let Some(spi) = sps.iter().position(|sp| sp.sheet_uuid == *suuid && sp.name == l.text) else { continue };+            let (Some(&cg), Some(&pg)) = (child.conn.of.get(&Item::Label(li)), par.conn.of.get(&Item::SheetPin(spi))) else { continue };+            d.union(child.offset + cg, par.offset + pg);+        }+    }+    // names per global net: power/global first, then the shallowest local or hierarchical label+    let mut best: HashMap<usize, (u8, usize, String)> = HashMap::new();+    for (i, s) in sheets.iter().enumerate() {+        let Some(s) = s else { continue };+        let depth = h.instances[i].depth;+        let human = h.instances[i].human.clone();+        for (g, grp) in s.conn.groups.iter().enumerate() {+            let root = d.find(s.offset + g);+            for (k, t) in &grp.names {+                let (rank, name) = match k.as_str() {+                    "power" | "global" => (0u8, t.clone()),+                    "local" => (1, format!("{human}{t}")),+                    "hierarchical" => (2, format!("{human}{t}")),+                    _ => continue,+                };+                let cand = (rank, depth, name);+                let e = best.entry(root).or_insert_with(|| cand.clone());+                if (cand.0, cand.1, &cand.2) < (e.0, e.1, &e.2) {+                    *e = cand;+                }+            }+        }+    }+    // parts and pins+    let mut parts: Vec<RPart> = Vec::new();+    let mut by_ref: HashMap<String, usize> = HashMap::new();+    let mut occurrences = Vec::new();+    let mut members: HashMap<usize, Vec<(String, String)>> = HashMap::new();+    for s in sheets.iter().flatten() {+        for p in s.model.pins.iter().filter(|p| !p.power_symbol) {+            let i = s.model.pins.iter().position(|q| std::ptr::eq(q, p)).unwrap_or(0);+            if let Some(&g) = s.conn.of.get(&Item::Pin(i)) {+                members.entry(d.find(s.offset + g)).or_default().push((p.reference.clone(), p.number.clone()));+            }+        }+    }+    for (i, s) in sheets.iter().enumerate() {+        let Some(s) = s else { continue };+        let human = h.instances[i].human.clone();+        for sym in s.model.symbols.iter().filter(|x| !x.is_power) {+            occurrences.push((sym.reference.clone(), sym.unit, format!("{human} {}", sym.uuid)));+            let pi = *by_ref.entry(sym.reference.clone()).or_insert_with(|| {+                parts.push(RPart { reference: sym.reference.clone(), value: sym.value.clone(), lib_id: sym.lib_id.clone(), footprint: sym.footprint.clone(), description: sym.description.clone(), pins: vec![] });+                parts.len() - 1+            });+            for (k, p) in s.model.pins.iter().enumerate().filter(|(_, p)| p.symbol_uuid == sym.uuid) {+                let Some(&g) = s.conn.of.get(&Item::Pin(k)) else { continue };+                let root = d.find(s.offset + g);+                let net = match best.get(&root) {+                    Some((_, _, n)) => n.clone(),+                    None => {+                        let mut ms = members.get(&root).cloned().unwrap_or_default();+                        ms.sort_by(|a, b| ref_order(&a.0).cmp(&ref_order(&b.0)).then(a.1.cmp(&b.1)));+                        ms.dedup();+                        if ms.len() <= 1 {+                            format!("unconnected-({}-Pad{})", p.reference, p.number)+                        } else {+                            format!("Net-({}-Pad{})", ms[0].0, ms[0].1)+                        }+                    }+                };+                if !parts[pi].pins.iter().any(|x| x.number == p.number) {+                    parts[pi].pins.push(RPin { number: p.number.clone(), name: p.name.clone(), electrical: p.electrical.clone(), net });+                }+            }+        }+    }+    let missing: Vec<String> = h.instances.iter().filter_map(|i| i.problem.as_ref().map(|p| format!("{}: {p}", i.human))).collect();+    let mut notes = vec!["Compiled by the bridge from the files (no kicad-cli): global labels and power symbols join by name, hierarchical labels join their sheet pins, hidden power pins join the net of their name. Buses are not expanded.".to_string()];+    if !missing.is_empty() {+        notes.push(format!("Sheets not reviewed: {}", missing.join("; ")));+    }+    let mut m = ReviewModel { parts, power_named, occurrences, source: "file", sheets: sheets.iter().flatten().count(), notes, ..Default::default() };+    m.index_nets();+    m+}++// ---------------------------------------------------------------------------+// Classification+// ---------------------------------------------------------------------------++fn tail_name(net: &str) -> String {+    net.rsplit('/').next().unwrap_or(net).to_ascii_uppercase()+}++pub fn is_ground(net: &str) -> bool {+    let n = tail_name(net);+    matches!(n.as_str(), "GND" | "GNDA" | "GNDD" | "GNDPWR" | "GNDREF" | "AGND" | "DGND" | "PGND" | "SGND" | "VSS" | "VSSA" | "0V" | "EARTH" | "CHASSIS")+        || n.starts_with("GND")+        || n.ends_with("GND")+}++fn looks_like_rail(net: &str) -> bool {+    let n = tail_name(net);+    if n.starts_with('+') || n.starts_with('-') && n.chars().nth(1).map_or(false, |c| c.is_ascii_digit()) {+        return true;+    }+    for p in ["VCC", "VDD", "VBUS", "VBAT", "VIN", "VSYS", "VREF", "VIO", "VAA", "AVDD", "DVDD", "IOVDD", "VMOT", "VOUT", "VREG"] {+        if n.starts_with(p) {+            return true;+        }+    }+    // 3V3, 5V, 12V, 1V8, V3V3+    let core = n.trim_start_matches('V');+    let mut chars = core.chars().peekable();+    let mut digits = 0;+    while chars.peek().map_or(false, |c| c.is_ascii_digit()) {+        chars.next();+        digits += 1;+    }+    digits > 0 && chars.next() == Some('V') && chars.all(|c| c.is_ascii_digit() || c == '_' || c.is_ascii_alphabetic())+}++#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub enum PartKind {+    Capacitor,+    Resistor,+    Led,+    Regulator,+    Ic,+    Connector,+    Other,+}++fn prefix(r: &str) -> String {+    r.chars().take_while(|c| c.is_ascii_alphabetic()).collect::<String>().to_ascii_uppercase()+}++pub fn kind(p: &RPart) -> PartKind {+    let pre = prefix(&p.reference);+    let lib = p.lib_id.to_ascii_lowercase();+    let sym = lib.rsplit(':').next().unwrap_or(&lib).to_string();+    let val = p.value.to_ascii_lowercase();+    if lib.contains("led") && (pre == "D" || pre == "LED") || sym.starts_with("led") {+        return PartKind::Led;+    }+    if pre == "C" || sym == "c" || sym.starts_with("c_") || sym == "cp" || sym.starts_with("cp_") {+        return PartKind::Capacitor;+    }+    if pre == "R" || sym == "r" || sym.starts_with("r_") {+        return PartKind::Resistor;+    }+    if pre == "J" || pre == "P" || pre == "CN" || lib.starts_with("connector") {+        return PartKind::Connector;+    }+    if lib.starts_with("regulator_") || val.contains("ldo") || val.contains("1117") || lib.contains("regulator") {+        return PartKind::Regulator;+    }+    if pre == "U" || pre == "IC" {+        let has_in = p.pins.iter().any(|x| matches!(norm_pin(&x.name).as_str(), "VIN" | "VI" | "IN"));+        let has_out = p.pins.iter().any(|x| matches!(norm_pin(&x.name).as_str(), "VOUT" | "VO" | "OUT") && x.electrical == "power_out");+        return if has_in && has_out { PartKind::Regulator } else { PartKind::Ic };+    }+    PartKind::Other+}++/// Pin name without KiCad's overbar markup and separators: `~{RESET}` -> RESET.+pub fn norm_pin(name: &str) -> String {+    name.replace("~{", "").replace(['}', '~', '/', '#', ' '], "").to_ascii_uppercase()+}++/// Farads from a value like 100n, 4.7uF, 10µ, 1u0, 22pF; None when unreadable.+pub fn farads(v: &str) -> Option<f64> {+    let s = v.trim().replace('µ', "u").replace('μ', "u").to_ascii_lowercase();+    let s = s.split([' ', '/', ',']).next().unwrap_or("");+    let s = s.trim_end_matches('f');+    let mut num = String::new();+    let mut mult = 1.0;+    let mut seen_unit = false;+    for c in s.chars() {+        if c.is_ascii_digit() || c == '.' {+            num.push(c);+        } else if !seen_unit {+            mult = match c {+                'p' => 1e-12,+                'n' => 1e-9,+                'u' => 1e-6,+                'm' => 1e-3,+                _ => return None,+            };+            if !num.contains('.') {+                num.push('.');+            }+            seen_unit = true;+        } else {+            return None;+        }+    }+    if !seen_unit {+        // a bare number on a capacitor is farads only in theory; KiCad users mean the unit they wrote+        return None;+    }+    let x: f64 = num.trim_end_matches('.').parse().ok()?;+    Some(x * mult)+}++fn fmt_f(f: f64) -> String {+    if f >= 1e-6 {+        format!("{}u", crate::schematic::fmt_num(f / 1e-6))+    } else if f >= 1e-9 {+        format!("{}n", crate::schematic::fmt_num(f / 1e-9))+    } else {+        format!("{}p", crate::schematic::fmt_num(f / 1e-12))+    }+}++#[derive(Clone, Debug, Serialize)]+pub struct ReviewFinding {+    pub check: &'static str,+    /// error | warning | info+    pub severity: &'static str,+    pub refs: Vec<String>,+    pub nets: Vec<String>,+    pub message: String,+    pub why: String,+    pub fix: String,+}++pub const CHECKS: &[&str] = &["decoupling", "i2c_pullups", "reset_enable", "led_resistor", "regulator_caps", "bulk_capacitance", "annotation"];++struct Ctx<'a> {+    m: &'a ReviewModel,+    rails: BTreeSet<String>,+}++impl<'a> Ctx<'a> {+    fn new(m: &'a ReviewModel) -> Ctx<'a> {+        let mut rails = BTreeSet::new();+        for (net, members) in &m.nets {+            if is_ground(net) {+                continue;+            }+            let has_power_out = members.iter().any(|(p, k)| m.parts[*p].pins[*k].electrical == "power_out");+            if m.power_named.contains(net) || looks_like_rail(net) || has_power_out {+                rails.insert(net.clone());+            }+        }+        Ctx { m, rails }+    }+    fn parts_on(&self, net: &str) -> Vec<(usize, usize)> {+        self.m.nets.get(net).cloned().unwrap_or_default()+    }+    /// Two-terminal parts of `kind` with one pin on `net`: (part, the other pin's net).+    fn two_terminal(&self, net: &str, want: PartKind) -> Vec<(usize, String)> {+        let mut out = Vec::new();+        for (pi, k) in self.parts_on(net) {+            let p = &self.m.parts[pi];+            if kind(p) != want || p.pins.len() != 2 {+                continue;+            }+            let other = &p.pins[1 - k].net;+            if other != net {+                out.push((pi, other.clone()));+            }+        }+        out+    }+    fn caps_to_ground(&self, net: &str) -> Vec<(usize, Option<f64>)> {+        self.two_terminal(net, PartKind::Capacitor).into_iter().filter(|(_, o)| is_ground(o)).map(|(p, _)| (p, farads(&self.m.parts[p].value))).collect()+    }+    fn single(&self, net: &str) -> bool {+        net.starts_with("unconnected-") || self.parts_on(net).len() <= 1+    }+}++pub fn review(m: &ReviewModel, only: Option<&[String]>) -> Vec<ReviewFinding> {+    let on = |c: &str| only.map_or(true, |o| o.iter().any(|x| x == c));+    let cx = Ctx::new(m);+    let mut out = Vec::new();++    if on("decoupling") {+        for p in m.parts.iter().filter(|p| kind(p) == PartKind::Ic) {+            let mut nets: BTreeMap<&str, Vec<String>> = BTreeMap::new();+            for pin in p.pins.iter().filter(|x| x.electrical == "power_in" && !is_ground(&x.net) && !x.net.starts_with("unconnected-")) {+                nets.entry(pin.net.as_str()).or_default().push(format!("{} {}", pin.number, pin.name));+            }+            for (net, pins) in nets {+                if cx.caps_to_ground(net).is_empty() {+                    out.push(ReviewFinding {+                        check: "decoupling",+                        severity: "warning",+                        refs: vec![p.reference.clone()],+                        nets: vec![net.to_string()],+                        message: format!("{} power pin(s) {} on {net} have no decoupling capacitor to ground", p.reference, pins.join(", ")),+                        why: "An IC draws its switching current in fast edges; without a local capacitor that current comes through the rail's inductance and the supply at the pin droops and rings.".into(),+                        fix: format!("Place a 100 nF ceramic from {net} to GND for each power pin group of {}, beside the pin on the board (kicad_sch_place_symbol Device:C, then kicad_sch_wire both ends).", p.reference),+                    });+                }+            }+        }+    }++    if on("i2c_pullups") {+        for net in m.nets.keys() {+            let tail = tail_name(net);+            let toks: Vec<&str> = tail.split(|c: char| !c.is_ascii_alphanumeric()).collect();+            let named = toks.iter().any(|t| t.starts_with("SDA") || t.starts_with("SCL"));+            let by_pin = cx.parts_on(net).iter().any(|(p, k)| matches!(norm_pin(&m.parts[*p].pins[*k].name).as_str(), "SDA" | "SCL" | "I2C_SDA" | "I2C_SCL"));+            if !(named || by_pin) || cx.single(net) {+                continue;+            }+            let pullups: Vec<usize> = cx.two_terminal(net, PartKind::Resistor).into_iter().filter(|(_, o)| cx.rails.contains(o)).map(|(p, _)| p).collect();+            if pullups.is_empty() {+                let line = if tail.contains("SCL") { "SCL" } else { "SDA" };+                out.push(ReviewFinding {+                    check: "i2c_pullups",+                    severity: "warning",+                    refs: cx.parts_on(net).iter().map(|(p, _)| m.parts[*p].reference.clone()).collect::<BTreeSet<_>>().into_iter().collect(),+                    nets: vec![net.clone()],+                    message: format!("I2C line {net} has no pull-up resistor to a supply rail"),+                    why: format!("I2C drivers are open drain: they only pull low. Without a pull-up, {line} never returns high and the bus does not work (unless the pull-up sits on another board, which the schematic should say)."),+                    fix: format!("Add a resistor from {net} to the I/O rail (4.7k for 100 kHz, 2.2k for 400 kHz): kicad_sch_place_symbol Device:R and wire it, once per bus."),+                });+            }+        }+    }++    if on("reset_enable") {+        const NAMES: &[&str] = &["RESET", "RST", "NRST", "RESETN", "RSTN", "NRESET", "MCLR", "EN", "ENABLE", "CE", "CHIP_EN", "CHIPEN", "SHDN", "NSHDN", "SHDNN", "PWRDN", "ON", "ONOFF", "RUN"];+        for p in m.parts.iter().filter(|p| matches!(kind(p), PartKind::Ic | PartKind::Regulator)) {+            for pin in &p.pins {+                let n = norm_pin(&pin.name);+                let hit = NAMES.contains(&n.as_str()) || n.starts_with("RESET") || n.starts_with("NRST") || n.starts_with("RST");+                if !hit || matches!(pin.electrical.as_str(), "output" | "power_out" | "no_connect") {+                    continue;+                }+                if cx.single(&pin.net) {+                    out.push(ReviewFinding {+                        check: "reset_enable",+                        severity: "error",+                        refs: vec![p.reference.clone()],+                        nets: vec![pin.net.clone()],+                        message: format!("{}.{} {} is floating", p.reference, pin.number, pin.name),+                        why: format!("A floating {} input picks up noise: the part may never start, or reset and shut down at random.", pin.name),+                        fix: format!("Tie {}.{} to its active state (to the supply for an enable that should always be on, or through a 10k pull-up with a 100 nF to ground for a reset), or drive it from a GPIO.", p.reference, pin.number),+                    });+                    continue;+                }+                let defined = cx.rails.contains(&pin.net)+                    || is_ground(&pin.net)+                    || cx.parts_on(&pin.net).iter().any(|(q, k)| {+                        let qp = &m.parts[*q];+                        let e = qp.pins[*k].electrical.as_str();+                        (qp.reference != p.reference && matches!(e, "output" | "bidirectional" | "open_collector" | "open_emitter" | "power_out" | "tri_state"))+                            || matches!(kind(qp), PartKind::Resistor | PartKind::Connector)+                    });+                if !defined {+                    out.push(ReviewFinding {+                        check: "reset_enable",+                        severity: "warning",+                        refs: vec![p.reference.clone()],+                        nets: vec![pin.net.clone()],+                        message: format!("Nothing sets the level of {}.{} {} (net {})", p.reference, pin.number, pin.name, pin.net),+                        why: "The net reaches the pin but no driver, pull resistor, rail or connector defines it, so it floats like an open pin.".into(),+                        fix: format!("Add a pull resistor on {} to the inactive level, or drive it from a GPIO or a supervisor.", pin.net),+                    });+                }+            }+        }+    }++    if on("led_resistor") {+        for (pi, p) in m.parts.iter().enumerate().filter(|(_, p)| kind(p) == PartKind::Led) {+            let limited = p.pins.iter().any(|pin| cx.parts_on(&pin.net).iter().any(|(q, _)| *q != pi && kind(&m.parts[*q]) == PartKind::Resistor));+            if !limited {+                out.push(ReviewFinding {+                    check: "led_resistor",+                    severity: "warning",+                    refs: vec![p.reference.clone()],+                    nets: p.pins.iter().map(|x| x.net.clone()).collect(),+                    message: format!("LED {} has no series resistor on either side", p.reference),+                    why: "An LED's current rises steeply above its forward voltage: across a rail it draws whatever the supply gives and burns out (or overloads the driving pin).".into(),+                    fix: format!("Put a resistor in series with {}: R = (Vsupply - Vf) / I, for example 330R to 1k at 3.3 V; skip only if a constant-current driver feeds it.", p.reference),+                });+            }+        }+    }++    if on("regulator_caps") {+        for p in m.parts.iter().filter(|p| kind(p) == PartKind::Regulator) {+            let inputs: BTreeSet<&str> = p.pins.iter().filter(|x| (x.electrical == "power_in" || matches!(norm_pin(&x.name).as_str(), "VIN" | "VI" | "IN")) && !is_ground(&x.net) && !x.net.starts_with("unconnected-")).map(|x| x.net.as_str()).collect();+            let outputs: BTreeSet<&str> = p.pins.iter().filter(|x| x.electrical == "power_out" || matches!(norm_pin(&x.name).as_str(), "VOUT" | "VO" | "OUT")).filter(|x| !x.net.starts_with("unconnected-")).map(|x| x.net.as_str()).collect();+            let inputs: Vec<&str> = inputs.difference(&outputs).copied().collect();+            for net in inputs {+                if cx.caps_to_ground(net).is_empty() {+                    out.push(ReviewFinding {+                        check: "regulator_caps",+                        severity: "warning",+                        refs: vec![p.reference.clone()],+                        nets: vec![net.to_string()],+                        message: format!("Regulator {} has no input capacitor on {net}", p.reference),+                        why: "A regulator's input needs a low-impedance local source; with long supply wiring the input rings and the regulator can oscillate or drop out on load steps.".into(),+                        fix: format!("Add the input capacitor the datasheet asks for (typically 1 uF to 10 uF ceramic) from {net} to GND beside {}.", p.reference),+                    });+                }+            }+            for net in outputs {+                let caps = cx.caps_to_ground(net);+                let biggest = caps.iter().filter_map(|(_, f)| *f).fold(None, |a: Option<f64>, f| Some(a.map_or(f, |x| x.max(f))));+                let unknown = caps.iter().any(|(_, f)| f.is_none());+                if caps.is_empty() {+                    out.push(ReviewFinding {+                        check: "regulator_caps",+                        severity: "warning",+                        refs: vec![p.reference.clone()],+                        nets: vec![net.to_string()],+                        message: format!("Regulator {} has no output capacitor on {net}", p.reference),+                        why: "Most LDOs are only stable with a minimum output capacitance (often 1 uF or more, sometimes with an ESR window); without it the output can oscillate.".into(),+                        fix: format!("Add the datasheet's output capacitor (typically 1 uF to 10 uF ceramic) from {net} to GND beside {}.", p.reference),+                    });+                } else if !unknown && biggest.map_or(false, |b| b < 0.99e-6) {+                    out.push(ReviewFinding {+                        check: "regulator_caps",+                        severity: "warning",+                        refs: vec![p.reference.clone()],+                        nets: vec![net.to_string()],+                        message: format!("Regulator {} output {net} has only {} of capacitance", p.reference, fmt_f(biggest.unwrap_or(0.0))),+                        why: "Small decoupling capacitors elsewhere on the rail do not meet an LDO's stability minimum, which is usually 1 uF or more at the output pin.".into(),+                        fix: format!("Add a 1 uF to 10 uF ceramic from {net} to GND beside {} (check the datasheet's minimum and ESR range).", p.reference),+                    });+                }+            }+        }+    }++    if on("bulk_capacitance") {+        for net in &cx.rails {+            let loads = cx.parts_on(net).iter().filter(|(p, k)| m.parts[*p].pins[*k].electrical == "power_in" && matches!(kind(&m.parts[*p]), PartKind::Ic | PartKind::Regulator)).count();+            if loads == 0 {+                continue;+            }+            let caps = cx.caps_to_ground(net);+            if caps.iter().any(|(_, f)| f.map_or(true, |f| f >= 4.7e-6 * 0.99)) {+                continue;+            }+            let biggest = caps.iter().filter_map(|(_, f)| *f).fold(0.0, f64::max);+            out.push(ReviewFinding {+                check: "bulk_capacitance",+                severity: "info",+                refs: caps.iter().map(|(p, _)| m.parts[*p].reference.clone()).collect(),+                nets: vec![net.clone()],+                message: if caps.is_empty() { format!("Rail {net} has no capacitance to ground at all") } else { format!("Rail {net} has no bulk capacitor (largest is {})", fmt_f(biggest)) },+                why: "Decoupling capacitors handle fast edges; a rail also needs bulk capacitance (about 4.7 uF to 100 uF where it enters or is generated) for load steps and to keep the source impedance low.".into(),+                fix: format!("Add a 10 uF (or larger) capacitor from {net} to GND where the rail enters the board or leaves its regulator."),+            });+        }+    }++    if on("annotation") {+        let mut seen: BTreeMap<(String, u32), Vec<String>> = BTreeMap::new();+        let mut unannotated = BTreeSet::new();+        for (r, unit, wh) in &m.occurrences {+            if r.contains('?') || r.is_empty() {+                unannotated.insert(r.clone());+                continue;+            }+            seen.entry((r.clone(), *unit)).or_default().push(wh.clone());+        }+        for r in unannotated {+            out.push(ReviewFinding {+                check: "annotation",+                severity: "error",+                refs: vec![r.clone()],+                nets: vec![],+                message: format!("{} is not annotated", if r.is_empty() { "A symbol" } else { r.as_str() }),+                why: "An unannotated reference cannot become a footprint on the board; the netlist and BOM drop or merge it.".into(),+                fix: "Annotate (in the Schematic Editor: Tools > Annotate Schematic), or give it a reference with kicad_sch_set_property {name:\"Reference\"}.".into(),+            });+        }+        let mut dups: Vec<(String, usize)> = seen.iter().filter(|(_, v)| v.len() > 1).map(|((r, _), v)| (r.clone(), v.len())).collect();+        dups.sort();+        dups.dedup();+        for (r, n) in dups {+            out.push(ReviewFinding {+                check: "annotation",+                severity: "error",+                refs: vec![r.clone()],+                nets: vec![],+                message: format!("Reference {r} is used by {n} different symbols"),+                why: "Two parts with one reference become one footprint on the board: one of them silently disappears and their nets merge in the netlist.".into(),+                fix: format!("Renumber one of them (kicad_sch_set_property {{reference:\"{r}\", name:\"Reference\", value:\"<free ref>\"}} on its sheet), or re-annotate."),+            });+        }+    }+    out+}++pub fn summary(m: &ReviewModel, f: &[ReviewFinding]) -> Value {+    let count = |s: &str| f.iter().filter(|x| x.severity == s).count();+    let mut by_check = serde_json::Map::new();+    for c in CHECKS {+        by_check.insert(c.to_string(), json!(f.iter().filter(|x| x.check == *c).count()));+    }+    json!({+        "errors": count("error"),+        "warnings": count("warning"),+        "info": count("info"),+        "byCheck": by_check,+        "parts": m.parts.len(),+        "nets": m.nets.len(),+    })+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn farads_parse() {+        assert!((farads("100n").unwrap() - 100e-9).abs() < 1e-15);+        assert!((farads("4.7uF").unwrap() - 4.7e-6).abs() < 1e-12);+        assert!((farads("4u7").unwrap() - 4.7e-6).abs() < 1e-12);+        assert!((farads("10µF 16V").unwrap() - 10e-6).abs() < 1e-12);+        assert!((farads("22p").unwrap() - 22e-12).abs() < 1e-18);+        assert_eq!(farads("C"), None);+        assert_eq!(farads("10"), None);+    }++    #[test]+    fn ground_and_rails() {+        assert!(is_ground("GND") && is_ground("/Power/AGND") && is_ground("VSS") && !is_ground("+3V3"));+        assert!(looks_like_rail("+3V3") && looks_like_rail("VCC") && looks_like_rail("3V3") && looks_like_rail("/x/VDD_IO") && looks_like_rail("12V"));+        assert!(!looks_like_rail("SDA") && !looks_like_rail("Net-(R1-Pad1)"));+    }++    #[test]+    fn pin_names_normalise() {+        assert_eq!(norm_pin("~{RESET}"), "RESET");+        assert_eq!(norm_pin("EN"), "EN");+    }+}
rust/crates/kicad-core/src/sch_sheets.rsadded+685
@@ -0,0 +1,685 @@+//! Sheet operations on a project: add, edit, move, delete and duplicate a hierarchical+//! sheet, and add, edit, delete, import and validate its pins.+//!+//! A `Workspace` loads the project's whole sheet tree (root found through the `.kicad_pro`+//! beside the file, or the file itself when there is none) so a sheet added to a sub-sheet+//! gets one instance entry per use of that sub-sheet, page numbers continue after the+//! highest one in the design, and a duplicated sheet's parts get references nobody else+//! uses. Operations change documents in memory and queue new files; `Workspace::commit`+//! writes them (a `.bak` beside each changed file, new files never overwrite).++use std::collections::BTreeSet;+use std::path::{Path, PathBuf};++use serde_json::{json, Value};++use crate::schematic::{fmt_num, is_symbol_instance, pretty, Node, Schematic};+use crate::sch_hier::{self as hier, Hierarchy, Paper, RefAllocator, Side, TitleEdit};+use crate::sch_model::{self, SheetRef};++pub struct Workspace {+    pub h: Hierarchy,+    /// The document being edited (index into `h.docs`).+    pub target: usize,+    pub file: PathBuf,+    /// Files to create: (path, text).+    pub new_files: Vec<(PathBuf, String)>,+    pub notes: Vec<String>,+}++#[derive(Debug)]+pub struct Written {+    pub changed: Vec<(PathBuf, PathBuf)>,+    pub created: Vec<PathBuf>,+}++fn snap(v: f64) -> f64 {+    (v / 1.27).round() * 1.27+}++fn r(v: f64) -> f64 {+    (v * 1e4).round() / 1e4 + 0.0+}++impl Workspace {+    /// Open `file` inside its project. `root` overrides the project root sheet.+    pub fn open(file: &Path, root: Option<&Path>) -> Result<Workspace, String> {+        let root_path = root.map(Path::to_path_buf).or_else(|| hier::project_root_for(file)).unwrap_or_else(|| file.to_path_buf());+        let mut notes = Vec::new();+        let mut h = Hierarchy::load(&root_path)?;+        let target = match h.doc_of(file) {+            Some(t) if h.paths_of(file).len() > 0 => t,+            _ => {+                if root.is_some() {+                    return Err(format!("{} is not a sheet of the hierarchy under {}", file.display(), root_path.display()));+                }+                notes.push(format!("{} is not reachable from the project root {}; it was edited as its own root.", file.display(), root_path.display()));+                h = Hierarchy::load(file)?;+                0+            }+        };+        Ok(Workspace { h, target, file: file.to_path_buf(), new_files: Vec::new(), notes })+    }++    pub fn sch(&self) -> &Schematic {+        &self.h.docs[self.target].1+    }+    pub fn sch_mut(&mut self) -> &mut Schematic {+        self.h.changed.insert(self.target);+        &mut self.h.docs[self.target].1+    }+    /// Instance paths of the edited file.+    pub fn paths(&self) -> Vec<String> {+        let p = self.h.paths_of(&self.file);+        if p.is_empty() { vec![format!("/{}", self.sch().root_uuid().unwrap_or_default())] } else { p }+    }++    /// Every file this commit would touch: (path, is new).+    pub fn plan(&self) -> Vec<(PathBuf, bool)> {+        let mut v: Vec<(PathBuf, bool)> = self.h.changed.iter().map(|&d| (self.h.docs[d].0.clone(), false)).collect();+        v.extend(self.new_files.iter().map(|(p, _)| (p.clone(), true)));+        v+    }++    pub fn commit(&self) -> Result<Written, String> {+        for (p, _) in &self.new_files {+            if p.exists() {+                return Err(format!("{} already exists; nothing was written", p.display()));+            }+        }+        let mut created = Vec::new();+        for (p, text) in &self.new_files {+            if let Some(d) = p.parent() {+                std::fs::create_dir_all(d).map_err(|e| format!("cannot create {}: {e}", d.display()))?;+            }+            crate::libraries::write_atomic(p, text.as_bytes())?;+            created.push(p.clone());+        }+        let mut changed = Vec::new();+        for &d in &self.h.changed {+            let (p, s) = &self.h.docs[d];+            let bak = s.save_with_backup(p)?;+            changed.push((p.clone(), bak));+        }+        Ok(Written { changed, created })+    }++    fn sheets(&self) -> Vec<SheetRef> {+        self.sch().doc.root.children().iter().filter(|n| n.head() == Some("sheet")).map(sch_model::sheet_ref).collect()+    }++    fn sheet_index(&self, key: &str) -> Result<usize, String> {+        hier::find_sheet(self.sch(), key)+    }++    fn sheet_at(&self, idx: usize) -> SheetRef {+        sch_model::sheet_ref(&self.sch().doc.root.children()[idx])+    }++    fn alloc(&self) -> RefAllocator {+        RefAllocator { used: self.h.references() }+    }++    /// A free spot right of the sheets already on this page.+    fn free_spot(&self, size: (f64, f64)) -> (f64, f64) {+        let sheets = self.sheets();+        if sheets.is_empty() {+            return (25.4, 25.4);+        }+        let right = sheets.iter().map(|s| s.at.0 + s.size.0).fold(0.0, f64::max);+        let top = sheets.iter().map(|s| s.at.1).fold(f64::MAX, f64::min);+        let paper = Paper::of(self.sch()).size_mm();+        let x = snap(right + 12.7);+        if x + size.0 < paper.0 - 10.0 {+            (r(x), r(snap(top)))+        } else {+            let bottom = sheets.iter().map(|s| s.at.1 + s.size.1).fold(0.0, f64::max);+            (25.4, r(snap(bottom + 12.7)))+        }+    }++    // -----------------------------------------------------------------------+    // sheets+    // -----------------------------------------------------------------------++    pub fn add_sheet(&mut self, name: &str, file: &str, at: Option<(f64, f64)>, size: Option<(f64, f64)>, import_pins: bool) -> Result<Value, String> {+        let name = name.trim();+        if name.is_empty() {+            return Err("a sheet needs a name".into());+        }+        if self.sheets().iter().any(|s| s.name.eq_ignore_ascii_case(name)) {+            return Err(format!("this sheet already has a sheet named {name}; sheet names must be unique on a page"));+        }+        let file = file.trim().replace('\\', "/");+        if !file.to_ascii_lowercase().ends_with(".kicad_sch") {+            return Err(format!("file {file} must end in .kicad_sch"));+        }+        let child = hier::resolve_child(&self.file, &file);+        let ancestors: Vec<PathBuf> = self.ancestor_files();+        if ancestors.iter().any(|a| same_file(a, &child)) {+            return Err(format!("{file} is this sheet or one of its parents: a sheet cannot contain itself"));+        }+        let size = size.unwrap_or((25.4, 20.32));+        let at = at.map(|(x, y)| (r(snap(x)), r(snap(y)))).unwrap_or_else(|| self.free_spot(size));+        let project = self.h.project_name();+        let parent_paths = self.paths();+        let mut page = self.h.max_page();+        let pages: Vec<(String, String)> = parent_paths+            .iter()+            .map(|p| {+                page += 1;+                (p.clone(), page.to_string())+            })+            .collect();+        let (mut node, uuid) = hier::sheet_node(name, &file, at, size, &project, &pages);++        let mut created = false;+        let mut labels = Vec::new();+        let mut renames = Vec::new();+        if child.is_file() {+            let mut cs = Schematic::load(&child)?;+            labels = hier::hier_labels(&cs);+            // symbols in a linked file need an instance entry for each new use+            let mut alloc = self.alloc();+            let mut changed = false;+            for (p, _) in &pages {+                let new_path = format!("{p}/{uuid}");+                let rn = ensure_symbol_instances(&mut cs, &new_path, None, &project, &mut alloc);+                changed |= !rn.is_empty() || cs.doc.root.children().iter().any(|n| is_symbol_instance(n));+                renames.extend(rn);+            }+            if changed {+                let d = match self.h.doc_of(&child) {+                    Some(d) => {+                        self.h.docs[d].1 = cs;+                        d+                    }+                    None => {+                        self.h.docs.push((child.clone(), cs));+                        self.h.docs.len() - 1+                    }+                };+                self.h.changed.insert(d);+            }+        } else {+            let paper = Paper::of(self.sch());+            let (text, _) = hier::blank_sheet(&paper, &TitleEdit { title: Some(name.to_string()), ..Default::default() });+            self.new_files.push((child.clone(), text));+            created = true;+        }+        let mut pins = Vec::new();+        if import_pins && !labels.is_empty() {+            let mut sref = sch_model::sheet_ref(&node);+            for (lname, shape, _) in &labels {+                let side = hier::default_side(shape);+                let spot = hier::pin_spot(&sref, side, None)?;+                let (pin, puuid) = hier::sheet_pin_node(lname, shape, spot, side);+                hier::push_sheet_pin(&mut node, pin);+                sref = sch_model::sheet_ref(&node);+                pins.push(json!({"name": lname, "shape": shape, "side": side.name(), "at": [spot.0, spot.1], "uuid": puuid}));+            }+        }+        hier::insert_top(self.sch_mut(), node);+        Ok(json!({+            "uuid": uuid,+            "name": name,+            "file": file,+            "childPath": child.to_string_lossy().replace('\\', "/"),+            "childCreated": created,+            "at": [at.0, at.1],+            "size": [size.0, size.1],+            "pages": pages.iter().map(|(p, pg)| json!({"path": format!("{p}/{uuid}"), "page": pg})).collect::<Vec<_>>(),+            "pinsAdded": pins,+            "hierarchicalLabelsInChild": labels.len(),+            "renamed": renames.iter().map(|(a, b)| json!({"from": a, "to": b})).collect::<Vec<_>>(),+        }))+    }++    fn ancestor_files(&self) -> Vec<PathBuf> {+        let mut out = vec![self.file.clone()];+        for (i, inst) in self.h.instances.iter().enumerate() {+            if inst.doc.is_some() && same_file(&inst.file, &self.file) {+                let mut p = inst.parent;+                while let Some(k) = p {+                    out.push(self.h.instances[k].file.clone());+                    p = self.h.instances[k].parent;+                }+                let _ = i;+            }+        }+        out+    }++    pub fn edit_sheet(&mut self, key: &str, new_name: Option<&str>, file: Option<&str>, size: Option<(f64, f64)>, at: Option<(f64, f64)>) -> Result<Value, String> {+        let idx = self.sheet_index(key)?;+        let before = self.sheet_at(idx);+        let mut changes = Vec::new();+        if let Some(n) = new_name.map(str::trim).filter(|n| !n.is_empty()) {+            if n != before.name && self.sheets().iter().any(|s| s.uuid != before.uuid && s.name.eq_ignore_ascii_case(n)) {+                return Err(format!("another sheet on this page is already named {n}"));+            }+            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];+            hier::set_sheet_field(node, "Sheetname", n);+            changes.push(format!("name {} -> {n}", before.name));+        }+        let mut child_created = false;+        if let Some(f) = file.map(|f| f.trim().replace('\\', "/")).filter(|f| !f.is_empty()) {+            if !f.to_ascii_lowercase().ends_with(".kicad_sch") {+                return Err(format!("file {f} must end in .kicad_sch"));+            }+            let child = hier::resolve_child(&self.file, &f);+            if self.ancestor_files().iter().any(|a| same_file(a, &child)) {+                return Err(format!("{f} is this sheet or one of its parents: a sheet cannot contain itself"));+            }+            if !child.is_file() {+                let paper = Paper::of(self.sch());+                let (text, _) = hier::blank_sheet(&paper, &TitleEdit { title: Some(before.name.clone()), ..Default::default() });+                self.new_files.push((child.clone(), text));+                child_created = true;+            }+            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];+            hier::set_sheet_field(node, "Sheetfile", &f);+            changes.push(format!("file {} -> {f}{}", before.file, if child_created { " (created blank)" } else { "" }));+        }+        if let Some((w, h)) = size {+            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];+            hier::resize_sheet_node(node, r(w), r(h))?;+            changes.push(format!("size {}x{} -> {}x{}", fmt_num(before.size.0), fmt_num(before.size.1), fmt_num(r(w)), fmt_num(r(h))));+        }+        if let Some((x, y)) = at {+            let cur = self.sheet_at(idx).at;+            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];+            hier::move_sheet_node(node, x - cur.0, y - cur.1);+            changes.push(format!("at ({}, {}) -> ({}, {})", fmt_num(cur.0), fmt_num(cur.1), fmt_num(x), fmt_num(y)));+        }+        if changes.is_empty() {+            return Err("nothing to change: pass newName, file, size or at".into());+        }+        let after = self.sheet_at(idx);+        Ok(json!({"uuid": after.uuid, "name": after.name, "file": after.file, "at": [after.at.0, after.at.1], "size": [after.size.0, after.size.1], "changes": changes, "childCreated": child_created}))+    }++    pub fn move_sheet(&mut self, key: &str, to: Option<(f64, f64)>, by: Option<(f64, f64)>) -> Result<Value, String> {+        let idx = self.sheet_index(key)?;+        let s = self.sheet_at(idx);+        let (dx, dy) = match (to, by) {+            (Some(t), _) => (t.0 - s.at.0, t.1 - s.at.1),+            (None, Some(b)) => b,+            _ => return Err("pass to:[x, y] (new top-left corner) or by:[dx, dy]".into()),+        };+        let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];+        hier::move_sheet_node(node, dx, dy);+        let after = self.sheet_at(idx);+        Ok(json!({"uuid": s.uuid, "name": s.name, "from": [s.at.0, s.at.1], "to": [after.at.0, after.at.1], "pinsMoved": s.pins.len(), "note": "Wires attached to the sheet pins do not follow; redraw them or run kicad_sch_check to find the ends left behind."}))+    }++    pub fn delete_sheet(&mut self, key: &str) -> Result<Value, String> {+        let idx = self.sheet_index(key)?;+        let s = self.sheet_at(idx);+        self.sch_mut().doc.root.children_mut().expect("list").remove(idx);+        let child = hier::resolve_child(&self.file, &s.file);+        let still_used = self.h.instances.iter().filter(|i| i.sheet_uuid.as_deref() != Some(s.uuid.as_str()) && i.doc.is_some() && same_file(&i.file, &child)).count();+        Ok(json!({+            "uuid": s.uuid,+            "name": s.name,+            "file": s.file,+            "childFileKept": child.to_string_lossy().replace('\\', "/"),+            "childStillUsedElsewhere": still_used,+            "pinsRemoved": s.pins.len(),+        }))+    }++    pub fn duplicate_sheet(&mut self, key: &str, new_name: Option<&str>, file: Option<&str>, at: Option<(f64, f64)>) -> Result<Value, String> {+        let idx = self.sheet_index(key)?;+        let src = self.sheet_at(idx);+        let src_child = hier::resolve_child(&self.file, &src.file);+        if !src_child.is_file() {+            return Err(format!("the sheet's file {} does not exist; nothing to duplicate", src.file));+        }+        let name = match new_name.map(str::trim).filter(|n| !n.is_empty()) {+            Some(n) => n.to_string(),+            None => {+                let mut k = 2;+                loop {+                    let cand = format!("{}{k}", src.name);+                    if !self.sheets().iter().any(|s| s.name.eq_ignore_ascii_case(&cand)) {+                        break cand;+                    }+                    k += 1;+                }+            }+        };+        if self.sheets().iter().any(|s| s.name.eq_ignore_ascii_case(&name)) {+            return Err(format!("this page already has a sheet named {name}"));+        }+        let dir = src_child.parent().unwrap_or(Path::new(".")).to_path_buf();+        let stem = src_child.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "sheet".into());+        let file_rel = match file.map(|f| f.trim().replace('\\', "/")).filter(|f| !f.is_empty()) {+            Some(f) => f,+            None => {+                let fname = hier::free_copy_name(&dir, &stem);+                let parent_dir = self.file.parent().unwrap_or(Path::new("."));+                match dir.strip_prefix(parent_dir) {+                    Ok(rel) if rel.as_os_str().is_empty() => fname,+                    Ok(rel) => format!("{}/{fname}", rel.to_string_lossy().replace('\\', "/")),+                    Err(_) => dir.join(&fname).to_string_lossy().replace('\\', "/"),+                }+            }+        };+        let new_child = hier::resolve_child(&self.file, &file_rel);+        if new_child.exists() || self.new_files.iter().any(|(p, _)| same_file(p, &new_child)) {+            return Err(format!("{file_rel} already exists; pass another file name"));+        }+        let size = src.size;+        let at = at.unwrap_or((src.at.0 + src.size.0 + 12.7, src.at.1));+        let at = (r(snap(at.0)), r(snap(at.1)));+        let project = self.h.project_name();+        let parent_paths = self.paths();+        let mut page = self.h.max_page();+        let pages: Vec<(String, String)> = parent_paths+            .iter()+            .map(|p| {+                page += 1;+                (p.clone(), page.to_string())+            })+            .collect();+        let (mut node, uuid) = hier::sheet_node(&name, &file_rel, at, size, &project, &pages);+        // same pins as the source, same shapes, same edge offsets+        for p in &src.pins {+            let side = Side::of(&src, p.at);+            let spot = (r(p.at.0 - src.at.0 + at.0), r(p.at.1 - src.at.1 + at.1));+            let (pin, _) = hier::sheet_pin_node(&p.name, &p.shape, spot, side);+            hier::push_sheet_pin(&mut node, pin);+        }+        // the copy: fresh uuids, its own instance entries, references nobody else uses+        let mut copy = Schematic::load(&src_child)?;+        let old_path = format!("{}/{}", parent_paths[0], src.uuid);+        hier::refresh_uuids(&mut copy);+        let mut alloc = self.alloc();+        let mut renames = Vec::new();+        for (i, (p, _)) in pages.iter().enumerate() {+            let new_path = format!("{p}/{uuid}");+            if i == 0 {+                strip_symbol_instances(&mut copy);+            }+            renames.extend(ensure_symbol_instances(&mut copy, &new_path, Some(&old_path), &project, &mut alloc));+        }+        // sheets nested in the copy point at the same child files: those files gain instances+        let nested: Vec<SheetRef> = copy.doc.root.children().iter().filter(|n| n.head() == Some("sheet")).map(sch_model::sheet_ref).collect();+        let mut nested_note = Vec::new();+        if !nested.is_empty() {+            for (p, _) in &pages {+                let copy_path = format!("{p}/{uuid}");+                hier::retarget_sheet_instances(&mut copy, &copy_path, &project, &mut page);+            }+            for ns in &nested {+                let gfile = hier::resolve_child(&new_child, &ns.file);+                nested_note.push(format!("{} ({}) is shared with the original, not copied", ns.name, ns.file));+                if let Some(d) = self.h.doc_of(&gfile) {+                    for (p, _) in &pages {+                        let gpath = format!("{p}/{uuid}/{}", ns.uuid);+                        let rn = ensure_symbol_instances(&mut self.h.docs[d].1, &gpath, None, &project, &mut alloc);+                        renames.extend(rn);+                    }+                    self.h.changed.insert(d);+                }+            }+        }+        self.new_files.push((new_child.clone(), copy.to_text()));+        hier::insert_top(self.sch_mut(), node);+        renames.sort();+        renames.dedup();+        Ok(json!({+            "uuid": uuid,+            "name": name,+            "file": file_rel,+            "childPath": new_child.to_string_lossy().replace('\\', "/"),+            "from": {"uuid": src.uuid, "name": src.name, "file": src.file},+            "at": [at.0, at.1],+            "size": [size.0, size.1],+            "pages": pages.iter().map(|(p, pg)| json!({"path": format!("{p}/{uuid}"), "page": pg})).collect::<Vec<_>>(),+            "pinsCopied": src.pins.len(),+            "renamed": renames.iter().map(|(a, b)| json!({"from": a, "to": b})).collect::<Vec<_>>(),+            "sharedNestedSheets": nested_note,+        }))+    }++    // -----------------------------------------------------------------------+    // sheet pins+    // -----------------------------------------------------------------------++    fn child_labels(&self, s: &SheetRef) -> Result<Vec<(String, String, String)>, String> {+        let child = hier::resolve_child(&self.file, &s.file);+        if let Some(d) = self.h.doc_of(&child) {+            return Ok(hier::hier_labels(&self.h.docs[d].1));+        }+        if let Some((_, text)) = self.new_files.iter().find(|(p, _)| same_file(p, &child)) {+            return Ok(hier::hier_labels(&Schematic::parse(text)?));+        }+        if !child.is_file() {+            return Err(format!("the sheet's file {} does not exist", s.file));+        }+        Ok(hier::hier_labels(&Schematic::load(&child)?))+    }++    pub fn add_pin(&mut self, key: &str, name: &str, shape: &str, side: Option<Side>, at: Option<(f64, f64)>) -> Result<Value, String> {+        let idx = self.sheet_index(key)?;+        let s = self.sheet_at(idx);+        let name = name.trim();+        if name.is_empty() {+            return Err("a sheet pin needs a name (the hierarchical label it meets inside)".into());+        }+        let shape = shape.trim().to_ascii_lowercase();+        if !hier::PIN_SHAPES.contains(&shape.as_str()) {+            return Err(format!("shape {shape} is not one of {}", hier::PIN_SHAPES.join(", ")));+        }+        if s.pins.iter().any(|p| p.name == name) {+            return Err(format!("sheet {} already has a pin named {name}", s.name));+        }+        let side = side.or_else(|| at.map(|p| Side::of(&s, p))).unwrap_or_else(|| hier::default_side(&shape));+        let spot = hier::pin_spot(&s, side, at)?;+        let (pin, uuid) = hier::sheet_pin_node(name, &shape, spot, side);+        let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];+        hier::push_sheet_pin(node, pin);+        let has_label = self.child_labels(&s).map(|l| l.iter().any(|(n, _, _)| n == name)).unwrap_or(false);+        Ok(json!({"sheet": s.name, "name": name, "shape": shape, "side": side.name(), "at": [spot.0, spot.1], "uuid": uuid, "childHasLabel": has_label}))+    }++    fn pin_index(&self, sheet_idx: usize, pin: &str) -> Result<usize, String> {+        let node = &self.sch().doc.root.children()[sheet_idx];+        let hits: Vec<usize> = node.children().iter().enumerate().filter(|(_, c)| c.head() == Some("pin") && (c.arg(0).as_deref() == Some(pin) || c.child_value("uuid").as_deref() == Some(pin))).map(|(i, _)| i).collect();+        match hits.len() {+            1 => Ok(hits[0]),+            0 => Err(format!("sheet has no pin {pin} (pins: {})", node.children().iter().filter(|c| c.head() == Some("pin")).filter_map(|c| c.arg(0)).collect::<Vec<_>>().join(", "))),+            _ => Err(format!("{} pins are named {pin}; pass the pin's uuid", hits.len())),+        }+    }++    pub fn edit_pin(&mut self, key: &str, pin: &str, new_name: Option<&str>, shape: Option<&str>, side: Option<Side>, at: Option<(f64, f64)>) -> Result<Value, String> {+        let idx = self.sheet_index(key)?;+        let s = self.sheet_at(idx);+        let pi = self.pin_index(idx, pin)?;+        let mut changes = Vec::new();+        let old = s.pins.iter().find(|p| p.name == pin || p.uuid == pin).cloned().ok_or("pin vanished")?;+        if let Some(n) = new_name.map(str::trim).filter(|n| !n.is_empty() && *n != old.name) {+            if s.pins.iter().any(|p| p.name == n) {+                return Err(format!("sheet {} already has a pin named {n}", s.name));+            }+            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx].children_mut().expect("list")[pi];+            node.set_arg(0, Node::quoted(n));+            changes.push(format!("name {} -> {n}", old.name));+        }+        if let Some(sh) = shape.map(|x| x.trim().to_ascii_lowercase()) {+            if !hier::PIN_SHAPES.contains(&sh.as_str()) {+                return Err(format!("shape {sh} is not one of {}", hier::PIN_SHAPES.join(", ")));+            }+            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx].children_mut().expect("list")[pi];+            node.set_arg(1, Node::atom(sh.clone()));+            changes.push(format!("shape {} -> {sh}", old.shape));+        }+        if side.is_some() || at.is_some() {+            let mut others = s.clone();+            others.pins.retain(|p| p.uuid != old.uuid);+            let side = side.or_else(|| at.map(|p| Side::of(&s, p))).unwrap_or_else(|| Side::of(&s, old.at));+            let spot = hier::pin_spot(&others, side, at)?;+            let (angle, _) = match side {+                Side::Left => (180.0, ""),+                Side::Right => (0.0, ""),+                Side::Top => (90.0, ""),+                Side::Bottom => (270.0, ""),+            };+            let justify = match side {+                Side::Left | Side::Bottom => "left",+                _ => "right",+            };+            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx].children_mut().expect("list")[pi];+            if let Some(a) = node.child_mut("at") {+                a.set_arg(0, Node::number(spot.0));+                a.set_arg(1, Node::number(spot.1));+                a.set_arg(2, Node::number(angle));+            }+            if let Some(j) = node.child_mut("effects").and_then(|e| e.child_mut("justify")) {+                j.set_arg(0, Node::atom(justify));+            }+            changes.push(format!("moved to the {} edge at ({}, {})", side.name(), fmt_num(spot.0), fmt_num(spot.1)));+        }+        if changes.is_empty() {+            return Err("nothing to change: pass newName, shape, side or at".into());+        }+        Ok(json!({"sheet": s.name, "pin": old.name, "uuid": old.uuid, "changes": changes}))+    }++    pub fn delete_pin(&mut self, key: &str, pin: &str) -> Result<Value, String> {+        let idx = self.sheet_index(key)?;+        let s = self.sheet_at(idx);+        let pi = self.pin_index(idx, pin)?;+        let removed = self.sch_mut().doc.root.children_mut().expect("list")[idx].children_mut().expect("list").remove(pi);+        Ok(json!({"sheet": s.name, "name": removed.arg(0), "uuid": removed.child_value("uuid"), "note": "A wire that ended on the pin now dangles; kicad_sch_check lists it."}))+    }++    /// Pins for every hierarchical label in the child that the sheet lacks.+    pub fn import_pins(&mut self, key: &str) -> Result<Value, String> {+        let idx = self.sheet_index(key)?;+        let mut s = self.sheet_at(idx);+        let labels = self.child_labels(&s)?;+        let mut added = Vec::new();+        let missing: Vec<(String, String)> = labels.iter().filter(|(n, _, _)| !s.pins.iter().any(|p| p.name == *n)).map(|(n, sh, _)| (n.clone(), sh.clone())).collect();+        for (name, shape) in &missing {+            let side = hier::default_side(shape);+            let spot = hier::pin_spot(&s, side, None)?;+            let (pin, uuid) = hier::sheet_pin_node(name, shape, spot, side);+            let node = &mut self.sch_mut().doc.root.children_mut().expect("list")[idx];+            hier::push_sheet_pin(node, pin);+            s = self.sheet_at(idx);+            added.push(json!({"name": name, "shape": shape, "side": side.name(), "at": [spot.0, spot.1], "uuid": uuid}));+        }+        Ok(json!({"sheet": s.name, "labelsInChild": labels.len(), "added": added, "alreadyPresent": labels.len() - added.len()}))+    }++    pub fn validate_pins(&self, key: Option<&str>) -> Result<Value, String> {+        let sheets: Vec<SheetRef> = match key {+            Some(k) => vec![self.sheet_at(self.sheet_index(k)?)],+            None => self.sheets(),+        };+        let mut out = Vec::new();+        let mut problems = 0;+        for s in &sheets {+            match self.child_labels(s) {+                Ok(labels) => {+                    let (no_pin, no_label, mismatch) = hier::pin_label_diff(s, &labels);+                    problems += no_pin.len() + no_label.len() + mismatch.len();+                    out.push(json!({"sheet": s.name, "uuid": s.uuid, "file": s.file, "ok": no_pin.is_empty() && no_label.is_empty() && mismatch.is_empty(), "labelsWithoutPin": no_pin, "pinsWithoutLabel": no_label, "shapeMismatch": mismatch}));+                }+                Err(e) => {+                    problems += 1;+                    out.push(json!({"sheet": s.name, "uuid": s.uuid, "file": s.file, "ok": false, "error": e}));+                }+            }+        }+        Ok(json!({"ok": problems == 0, "problems": problems, "sheets": out}))+    }++    pub fn list_pins(&self, key: &str) -> Result<Value, String> {+        let s = self.sheet_at(self.sheet_index(key)?);+        Ok(json!({"sheet": s.name, "uuid": s.uuid, "file": s.file, "at": [s.at.0, s.at.1], "size": [s.size.0, s.size.1], "pins": s.pins.iter().map(|p| json!({"name": p.name, "shape": p.shape, "side": Side::of(&s, p.at).name(), "at": [p.at.0, p.at.1], "uuid": p.uuid})).collect::<Vec<_>>()}))+    }+}++fn same_file(a: &Path, b: &Path) -> bool {+    let ca = std::fs::canonicalize(a).unwrap_or_else(|_| a.to_path_buf());+    let cb = std::fs::canonicalize(b).unwrap_or_else(|_| b.to_path_buf());+    ca == cb+}++/// Remove every symbol's instances block (a copied file starts clean).+pub fn strip_symbol_instances(sch: &mut Schematic) {+    for n in sch.doc.root.children_mut().expect("list").iter_mut().filter(|n| is_symbol_instance(n)) {+        let c = n.children_mut().expect("list");+        if let Some(i) = c.iter().position(|x| x.head() == Some("instances")) {+            c.remove(i);+        }+    }+}++/// Give every symbol an instance entry for `path` when it lacks one. The reference is the one+/// it has under `from` (else its Reference field); a reference already used in the design is+/// replaced by the next free one (units of one part stay together). Returns the renames.+pub fn ensure_symbol_instances(sch: &mut Schematic, path: &str, from: Option<&str>, project: &str, alloc: &mut RefAllocator) -> Vec<(String, String)> {+    let mut renames = Vec::new();+    let mut chosen: std::collections::HashMap<String, String> = Default::default();+    let mut claimed: BTreeSet<String> = BTreeSet::new();+    for n in sch.doc.root.children_mut().expect("list").iter_mut().filter(|n| is_symbol_instance(n)) {+        let has = n.child("instances").map_or(false, |i| i.children().iter().any(|p| p.head() == Some("project") && p.children().iter().any(|q| q.head() == Some("path") && q.arg(0).as_deref() == Some(path))));+        if has {+            continue;+        }+        let base = sch_model::instance_reference(n, from);+        let reference = if base.starts_with('#') || base.ends_with('?') {+            base.clone()+        } else if let Some(r) = chosen.get(&base) {+            r.clone()+        } else {+            let r = if alloc.used.contains(&base) && !claimed.contains(&base) { alloc.next(&base) } else {+                alloc.used.insert(base.clone());+                base.clone()+            };+            claimed.insert(r.clone());+            chosen.insert(base.clone(), r.clone());+            r+        };+        if reference != base {+            renames.push((base.clone(), reference.clone()));+        }+        let unit = n.child("unit").and_then(|u| u.arg(0)).unwrap_or_else(|| "1".into());+        let entry = Node::list("path", vec![Node::quoted(path), Node::list("reference", vec![Node::quoted(&reference)]), Node::list("unit", vec![Node::atom(unit)])]);+        let children = n.children_mut().expect("list");+        match children.iter_mut().find(|c| c.head() == Some("instances")) {+            Some(insts) => {+                let ic = insts.children_mut().expect("list");+                if let Some(proj) = ic.iter_mut().find(|p| p.head() == Some("project")) {+                    let mut e = entry;+                    pretty(&mut e, 4);+                    proj.children_mut().expect("list").push(e);+                } else {+                    let mut proj = Node::list("project", vec![Node::quoted(project), entry]);+                    pretty(&mut proj, 3);+                    ic.push(proj);+                }+            }+            None => {+                let mut inst = Node::list("instances", vec![Node::list("project", vec![Node::quoted(project), entry])]);+                pretty(&mut inst, 2);+                children.push(inst);+            }+        }+        // a symbol used once shows its instance reference in the Reference field too+        if let Some(p) = children.iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some("Reference")) {+            if p.arg(1).as_deref() != Some(reference.as_str()) && from.is_some() {+                p.set_arg(1, Node::quoted(&reference));+            }+        }+    }+    renames.sort();+    renames.dedup();+    renames+}
rust/crates/kicad-core/src/schematic.rs+23−12
@@ -482,10 +482,10 @@ impl Schematic {         self.doc.root.child_value("version")     } -    fn top(&self) -> &[Node] {+    pub fn top(&self) -> &[Node] {         self.doc.root.children()     }-    fn top_mut(&mut self) -> &mut Vec<Node> {+    pub fn top_mut(&mut self) -> &mut Vec<Node> {         self.doc.root.children_mut().expect("root is a list")     } @@ -571,7 +571,7 @@ impl Schematic {         Ok(true)     } -    fn insert_item(&mut self, mut node: Node) {+    pub fn insert_item(&mut self, mut node: Node) {         pretty(&mut node, 1);         let top = self.top_mut();         let idx = top.iter().position(|n| n.head().map(|h| TOP_TAIL.contains(&h)).unwrap_or(false)).unwrap_or(top.len());@@ -584,8 +584,19 @@ impl Schematic {         if p.reference.trim().is_empty() {             return Err("reference is required".into());         }-        if self.find_symbol_index(p.reference).is_some() {-            return Err(format!("reference {} is already placed on this sheet", p.reference));+        // Another unit of a placed multi-unit part is fine (same lib id, a unit not yet+        // placed), and unannotated `X?` references may repeat; anything else is a clash.+        if !p.reference.trim().ends_with('?') {+            for n in self.top().iter().filter(|n| is_symbol_instance(n) && symbol_property(n, "Reference").as_deref() == Some(p.reference)) {+                let same_part = n.child_value("lib_id").as_deref() == Some(p.lib_id);+                let unit: u32 = n.child_value("unit").and_then(|u| u.parse().ok()).unwrap_or(1);+                if !same_part {+                    return Err(format!("reference {} is already placed on this sheet", p.reference));+                }+                if unit == p.unit {+                    return Err(format!("reference {} unit {} is already placed on this sheet", p.reference, p.unit));+                }+            }         }         let rot = norm_rotation(p.rotation)?;         let mut added = false;@@ -877,15 +888,15 @@ impl Schematic {     } } -fn xy(p: (f64, f64)) -> Node {+pub fn xy(p: (f64, f64)) -> Node {     Node::list("xy", vec![Node::number(p.0), Node::number(p.1)]) } -fn font_node() -> Node {+pub fn font_node() -> Node {     Node::list("font", vec![Node::list("size", vec![Node::number(1.27), Node::number(1.27)])]) } -fn property_node(name: &str, value: &str, at: (f64, f64), justify: Option<&str>, hide: bool) -> Node {+pub fn property_node(name: &str, value: &str, at: (f64, f64), justify: Option<&str>, hide: bool) -> Node {     let mut effects = vec![font_node()];     if let Some(j) = justify {         effects.push(Node::list("justify", vec![Node::atom(j)]));@@ -904,14 +915,14 @@ fn property_node(name: &str, value: &str, at: (f64, f64), justify: Option<&str>,     ) } -fn shift_at(at: &mut Node, dx: f64, dy: f64) {+pub fn shift_at(at: &mut Node, dx: f64, dy: f64) {     let x = at.num(0).unwrap_or(0.0) + dx;     let y = at.num(1).unwrap_or(0.0) + dy;     at.set_arg(0, Node::number(x));     at.set_arg(1, Node::number(y)); } -fn norm_rotation(r: f64) -> Result<f64, String> {+pub fn norm_rotation(r: f64) -> Result<f64, String> {     let r = ((r % 360.0) + 360.0) % 360.0;     if [0.0, 90.0, 180.0, 270.0].contains(&r) {         Ok(r)@@ -968,13 +979,13 @@ pub fn lib_symbol_pins(lib: &Node, base_name: &str, unit: u32) -> Vec<String> { }  /// `R_1_1` with base `R` -> unit 1.-fn sub_unit(name: &str, base: &str) -> Option<u32> {+pub fn sub_unit(name: &str, base: &str) -> Option<u32> {     let rest = name.strip_prefix(base)?.strip_prefix('_')?;     let (u, _style) = rest.split_once('_')?;     u.parse().ok() } -fn project_name_for(path: &Path) -> Option<String> {+pub fn project_name_for(path: &Path) -> Option<String> {     let dir = path.parent()?;     if let Ok(rd) = std::fs::read_dir(dir) {         let mut pros: Vec<String> = rd
rust/crates/kicad-core/src/silk_fields.rs+30
@@ -140,6 +140,36 @@ pub fn preserved_geometry(source: &str, ids: &HashSet<String>) -> Result<Value,     }     Ok(visit(&Document::parse(source).map_err(fail)?.root, ids)) }+/// Where two preserved_geometry trees first differ, as readable s-expression paths (at most `limit`).+pub fn preservation_diff(a: &Value, b: &Value, limit: usize) -> Vec<String> {+    fn label(v: &Value) -> String {+        match v {+            Value::Array(x) => x.first().and_then(Value::as_str).unwrap_or("?").to_string(),+            Value::String(s) => s.clone(),+            _ => v.to_string(),+        }+    }+    fn short(v: &Value) -> String {+        let s = v.to_string();+        if s.len() > 160 { format!("{}...", &s[..160]) } else { s }+    }+    fn walk(a: &Value, b: &Value, path: &str, out: &mut Vec<String>, limit: usize) {+        if out.len() >= limit || a == b {+            return;+        }+        match (a, b) {+            (Value::Array(x), Value::Array(y)) if x.len() == y.len() => {+                for (i, (p, q)) in x.iter().zip(y).enumerate() {+                    walk(p, q, &format!("{path}/{}[{i}]", label(p)), out, limit);+                }+            }+            _ => out.push(format!("{path}: {} -> {}", short(a), short(b))),+        }+    }+    let mut out = Vec::new();+    walk(a, b, "", &mut out, limit);+    out+} #[cfg(test)] mod tests {     use super::*;
rust/crates/kicad-core/src/svg_import.rsadded+600
@@ -0,0 +1,600 @@+//! SVG artwork to filled polygons in millimetres, in pure Rust: paths (every command,+//! curves and arcs flattened), rect, circle, ellipse, polygon and polyline, nested group+//! transforms, the root viewBox and its physical width/height. Holes (a subpath inside+//! another, by containment depth, which is what both fill rules give for ordinary logo+//! art) are joined to their outline by a zero-width cut, the way KiCad's own fractured+//! polygons are, because a KiCad `gr_poly` has no holes.++use quick_xml::events::Event;+use quick_xml::Reader;++pub type Pt = (f64, f64);++/// 2D affine matrix [a b c d e f]: x' = a x + c y + e, y' = b x + d y + f.+#[derive(Clone, Copy, Debug, PartialEq)]+pub struct Mat(pub [f64; 6]);++impl Mat {+    pub const ID: Mat = Mat([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]);+    pub fn mul(&self, o: &Mat) -> Mat {+        let [a, b, c, d, e, f] = self.0;+        let [a2, b2, c2, d2, e2, f2] = o.0;+        Mat([a * a2 + c * b2, b * a2 + d * b2, a * c2 + c * d2, b * c2 + d * d2, a * e2 + c * f2 + e, b * e2 + d * f2 + f])+    }+    pub fn apply(&self, p: Pt) -> Pt {+        let [a, b, c, d, e, f] = self.0;+        (a * p.0 + c * p.1 + e, b * p.0 + d * p.1 + f)+    }+    fn scale_factor(&self) -> f64 {+        let [a, b, c, d, _, _] = self.0;+        ((a * d - b * c).abs()).sqrt().max(1e-12)+    }+}++fn numbers(s: &str) -> Vec<f64> {+    let mut out = Vec::new();+    let b = s.as_bytes();+    let mut i = 0;+    while i < b.len() {+        let c = b[i] as char;+        if c.is_ascii_digit() || c == '-' || c == '+' || c == '.' {+            let start = i;+            let mut seen_dot = c == '.';+            let mut seen_e = false;+            i += 1;+            while i < b.len() {+                let d = b[i] as char;+                if d.is_ascii_digit() {+                    i += 1;+                } else if d == '.' && !seen_dot && !seen_e {+                    seen_dot = true;+                    i += 1;+                } else if (d == 'e' || d == 'E') && !seen_e {+                    seen_e = true;+                    i += 1;+                    if i < b.len() && (b[i] == b'-' || b[i] == b'+') {+                        i += 1;+                    }+                } else {+                    break;+                }+            }+            if let Ok(v) = s[start..i].parse::<f64>() {+                out.push(v);+            }+        } else {+            i += 1;+        }+    }+    out+}++/// An SVG `transform` attribute.+pub fn parse_transform(s: &str) -> Mat {+    let mut m = Mat::ID;+    let mut rest = s;+    while let Some(open) = rest.find('(') {+        let name = rest[..open].trim().trim_start_matches(',').trim();+        let Some(close) = rest[open..].find(')') else { break };+        let args = numbers(&rest[open + 1..open + close]);+        let t = match (name, args.as_slice()) {+            ("matrix", [a, b, c, d, e, f]) => Mat([*a, *b, *c, *d, *e, *f]),+            ("translate", [x]) => Mat([1.0, 0.0, 0.0, 1.0, *x, 0.0]),+            ("translate", [x, y]) => Mat([1.0, 0.0, 0.0, 1.0, *x, *y]),+            ("scale", [s]) => Mat([*s, 0.0, 0.0, *s, 0.0, 0.0]),+            ("scale", [sx, sy]) => Mat([*sx, 0.0, 0.0, *sy, 0.0, 0.0]),+            ("rotate", [a]) => {+                let (s, c) = a.to_radians().sin_cos();+                Mat([c, s, -s, c, 0.0, 0.0])+            }+            ("rotate", [a, cx, cy]) => {+                let (s, c) = a.to_radians().sin_cos();+                Mat([1.0, 0.0, 0.0, 1.0, *cx, *cy]).mul(&Mat([c, s, -s, c, 0.0, 0.0])).mul(&Mat([1.0, 0.0, 0.0, 1.0, -cx, -cy]))+            }+            ("skewX", [a]) => Mat([1.0, 0.0, a.to_radians().tan(), 1.0, 0.0, 0.0]),+            ("skewY", [a]) => Mat([1.0, a.to_radians().tan(), 0.0, 1.0, 0.0, 0.0]),+            _ => Mat::ID,+        };+        m = m.mul(&t);+        rest = &rest[open + close + 1..];+    }+    m+}++fn steps_for(len: f64, tol: f64) -> usize {+    ((len / tol).sqrt().ceil() as usize).clamp(4, 96)+}++fn cubic(p0: Pt, p1: Pt, p2: Pt, p3: Pt, tol: f64, out: &mut Vec<Pt>) {+    let len = dist(p0, p1) + dist(p1, p2) + dist(p2, p3);+    let n = steps_for(len, tol);+    for i in 1..=n {+        let t = i as f64 / n as f64;+        let u = 1.0 - t;+        let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);+        out.push((a * p0.0 + b * p1.0 + c * p2.0 + d * p3.0, a * p0.1 + b * p1.1 + c * p2.1 + d * p3.1));+    }+}++fn quad(p0: Pt, p1: Pt, p2: Pt, tol: f64, out: &mut Vec<Pt>) {+    let n = steps_for(dist(p0, p1) + dist(p1, p2), tol);+    for i in 1..=n {+        let t = i as f64 / n as f64;+        let u = 1.0 - t;+        out.push((u * u * p0.0 + 2.0 * u * t * p1.0 + t * t * p2.0, u * u * p0.1 + 2.0 * u * t * p1.1 + t * t * p2.1));+    }+}++fn dist(a: Pt, b: Pt) -> f64 {+    ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt()+}++/// SVG elliptical arc (endpoint parameterisation, F.6.5 of the SVG spec), flattened.+#[allow(clippy::too_many_arguments)]+fn arc(p0: Pt, rx: f64, ry: f64, phi_deg: f64, large: bool, sweep: bool, p1: Pt, tol: f64, out: &mut Vec<Pt>) {+    let (mut rx, mut ry) = (rx.abs(), ry.abs());+    if rx < 1e-12 || ry < 1e-12 || dist(p0, p1) < 1e-12 {+        out.push(p1);+        return;+    }+    let (s, c) = phi_deg.to_radians().sin_cos();+    let (dx, dy) = ((p0.0 - p1.0) / 2.0, (p0.1 - p1.1) / 2.0);+    let (x1, y1) = (c * dx + s * dy, -s * dx + c * dy);+    let lam = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry);+    if lam > 1.0 {+        rx *= lam.sqrt();+        ry *= lam.sqrt();+    }+    let num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1;+    let den = rx * rx * y1 * y1 + ry * ry * x1 * x1;+    let mut k = (num / den).max(0.0).sqrt();+    if large == sweep {+        k = -k;+    }+    let (cx1, cy1) = (k * rx * y1 / ry, -k * ry * x1 / rx);+    let (cx, cy) = (c * cx1 - s * cy1 + (p0.0 + p1.0) / 2.0, s * cx1 + c * cy1 + (p0.1 + p1.1) / 2.0);+    let ang = |ux: f64, uy: f64, vx: f64, vy: f64| {+        let d = (ux * vx + uy * vy) / ((ux * ux + uy * uy).sqrt() * (vx * vx + vy * vy).sqrt());+        let a = d.clamp(-1.0, 1.0).acos();+        if ux * vy - uy * vx < 0.0 { -a } else { a }+    };+    let t1 = ang(1.0, 0.0, (x1 - cx1) / rx, (y1 - cy1) / ry);+    let mut dt = ang((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry);+    if !sweep && dt > 0.0 {+        dt -= 2.0 * std::f64::consts::PI;+    } else if sweep && dt < 0.0 {+        dt += 2.0 * std::f64::consts::PI;+    }+    let n = steps_for(rx.max(ry) * dt.abs(), tol).max((dt.abs() / 0.2).ceil() as usize).min(180);+    for i in 1..=n {+        let t = t1 + dt * i as f64 / n as f64;+        let (st, ct) = t.sin_cos();+        out.push((c * rx * ct - s * ry * st + cx, s * rx * ct + c * ry * st + cy));+    }+    if let Some(l) = out.last_mut() {+        *l = p1;+    }+}++/// A path's `d` as closed rings (every subpath is closed for filling).+pub fn path_rings(d: &str, tol: f64) -> Vec<Vec<Pt>> {+    let mut rings: Vec<Vec<Pt>> = Vec::new();+    let mut cur: Vec<Pt> = Vec::new();+    let (mut p, mut start) = ((0.0, 0.0), (0.0, 0.0));+    let mut last_ctrl: Option<(char, Pt)> = None;+    // tokenise into commands and their numbers+    let mut cmds: Vec<(char, Vec<f64>)> = Vec::new();+    let mut buf = String::new();+    let mut cmd: Option<char> = None;+    for ch in d.chars() {+        if ch.is_ascii_alphabetic() && ch != 'e' && ch != 'E' {+            if let Some(c) = cmd {+                cmds.push((c, numbers(&buf)));+            }+            buf.clear();+            cmd = Some(ch);+        } else {+            buf.push(ch);+        }+    }+    if let Some(c) = cmd {+        cmds.push((c, numbers(&buf)));+    }+    let flush = |cur: &mut Vec<Pt>, rings: &mut Vec<Vec<Pt>>| {+        if cur.len() >= 3 {+            rings.push(std::mem::take(cur));+        } else {+            cur.clear();+        }+    };+    for (c, args) in cmds {+        let rel = c.is_ascii_lowercase();+        let base = |p: Pt, x: f64, y: f64| if rel { (p.0 + x, p.1 + y) } else { (x, y) };+        match c.to_ascii_uppercase() {+            'M' => {+                flush(&mut cur, &mut rings);+                for (i, xy) in args.chunks(2).enumerate() {+                    if xy.len() < 2 {+                        break;+                    }+                    p = base(p, xy[0], xy[1]);+                    if i == 0 {+                        start = p;+                    }+                    cur.push(p);+                }+                last_ctrl = None;+            }+            'L' => {+                for xy in args.chunks(2).filter(|c| c.len() == 2) {+                    p = base(p, xy[0], xy[1]);+                    cur.push(p);+                }+                last_ctrl = None;+            }+            'H' => {+                for x in &args {+                    p = (if rel { p.0 + x } else { *x }, p.1);+                    cur.push(p);+                }+                last_ctrl = None;+            }+            'V' => {+                for y in &args {+                    p = (p.0, if rel { p.1 + y } else { *y });+                    cur.push(p);+                }+                last_ctrl = None;+            }+            'C' => {+                for a in args.chunks(6).filter(|c| c.len() == 6) {+                    let (c1, c2, e) = (base(p, a[0], a[1]), base(p, a[2], a[3]), base(p, a[4], a[5]));+                    cubic(p, c1, c2, e, tol, &mut cur);+                    last_ctrl = Some(('C', c2));+                    p = e;+                }+            }+            'S' => {+                for a in args.chunks(4).filter(|c| c.len() == 4) {+                    let c1 = match last_ctrl {+                        Some(('C', q)) => (2.0 * p.0 - q.0, 2.0 * p.1 - q.1),+                        _ => p,+                    };+                    let (c2, e) = (base(p, a[0], a[1]), base(p, a[2], a[3]));+                    cubic(p, c1, c2, e, tol, &mut cur);+                    last_ctrl = Some(('C', c2));+                    p = e;+                }+            }+            'Q' => {+                for a in args.chunks(4).filter(|c| c.len() == 4) {+                    let (c1, e) = (base(p, a[0], a[1]), base(p, a[2], a[3]));+                    quad(p, c1, e, tol, &mut cur);+                    last_ctrl = Some(('Q', c1));+                    p = e;+                }+            }+            'T' => {+                for a in args.chunks(2).filter(|c| c.len() == 2) {+                    let c1 = match last_ctrl {+                        Some(('Q', q)) => (2.0 * p.0 - q.0, 2.0 * p.1 - q.1),+                        _ => p,+                    };+                    let e = base(p, a[0], a[1]);+                    quad(p, c1, e, tol, &mut cur);+                    last_ctrl = Some(('Q', c1));+                    p = e;+                }+            }+            'A' => {+                for a in args.chunks(7).filter(|c| c.len() == 7) {+                    let e = base(p, a[5], a[6]);+                    arc(p, a[0], a[1], a[2], a[3] != 0.0, a[4] != 0.0, e, tol, &mut cur);+                    p = e;+                }+                last_ctrl = None;+            }+            'Z' => {+                flush(&mut cur, &mut rings);+                p = start;+                cur.push(p);+                last_ctrl = None;+            }+            _ => {}+        }+    }+    flush(&mut cur, &mut rings);+    for r in rings.iter_mut() {+        dedup_ring(r);+    }+    rings.retain(|r| r.len() >= 3 && area(r).abs() > 1e-12);+    rings+}++fn dedup_ring(r: &mut Vec<Pt>) {+    r.dedup_by(|a, b| dist(*a, *b) < 1e-9);+    while r.len() > 1 && dist(r[0], *r.last().unwrap()) < 1e-9 {+        r.pop();+    }+}++pub fn area(r: &[Pt]) -> f64 {+    let mut s = 0.0;+    for i in 0..r.len() {+        let (a, b) = (r[i], r[(i + 1) % r.len()]);+        s += a.0 * b.1 - b.0 * a.1;+    }+    s / 2.0+}++fn inside(p: Pt, r: &[Pt]) -> bool {+    let mut c = false;+    let mut j = r.len() - 1;+    for i in 0..r.len() {+        let (a, b) = (r[i], r[j]);+        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;+        }+        j = i;+    }+    c+}++fn ellipse_ring(cx: f64, cy: f64, rx: f64, ry: f64, tol: f64) -> Vec<Pt> {+    let n = steps_for(2.0 * std::f64::consts::PI * rx.max(ry), tol).max(24).min(180);+    (0..n).map(|i| {+        let t = 2.0 * std::f64::consts::PI * i as f64 / n as f64;+        (cx + rx * t.cos(), cy + ry * t.sin())+    }).collect()+}++fn unit_mm(v: &str) -> Option<f64> {+    let v = v.trim();+    let num: String = v.chars().take_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == 'e' || *c == '+').collect();+    let x: f64 = num.parse().ok()?;+    let unit = v[num.len()..].trim();+    Some(x * match unit {+        "mm" => 1.0,+        "cm" => 10.0,+        "in" => 25.4,+        "pt" => 25.4 / 72.0,+        "pc" => 25.4 / 6.0,+        "" | "px" => 25.4 / 96.0,+        _ => return None,+    })+}++/// The parsed artwork: filled rings in SVG user units with the user-unit to mm factor+/// and the artwork's bounding box.+#[derive(Debug, Clone)]+pub struct Art {+    pub rings: Vec<Vec<Pt>>,+    pub mm_per_unit: f64,+    pub skipped: Vec<String>,+}++fn attr(e: &quick_xml::events::BytesStart, name: &str) -> Option<String> {+    e.attributes().flatten().find(|a| a.key.as_ref() == name.as_bytes()).and_then(|a| a.unescape_value().ok().map(|v| v.to_string()))+}++fn style_prop(style: &str, key: &str) -> Option<String> {+    style.split(';').find_map(|kv| {+        let (k, v) = kv.split_once(':')?;+        (k.trim() == key).then(|| v.trim().to_string())+    })+}++/// Parse SVG text into filled rings (user units, root viewBox applied).+pub fn parse_svg(text: &str, tol_mm: f64) -> Result<Art, String> {+    let mut reader = Reader::from_str(text);+    let mut stack: Vec<(Mat, bool)> = vec![(Mat::ID, true)];+    let mut rings = Vec::new();+    let mut skipped = Vec::new();+    let mut mm_per_unit = 25.4 / 96.0;+    let mut seen_root = false;+    let mut defs_depth = 0usize;+    loop {+        let ev = reader.read_event().map_err(|e| format!("SVG parse error: {e}"))?;+        let (e, empty) = match &ev {+            Event::Start(e) => (e.clone(), false),+            Event::Empty(e) => (e.clone(), true),+            Event::End(e) => {+                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();+                if name == "defs" || name == "clipPath" || name == "mask" || name == "symbol" {+                    defs_depth = defs_depth.saturating_sub(1);+                }+                if stack.len() > 1 {+                    stack.pop();+                }+                continue;+            }+            Event::Eof => break,+            _ => continue,+        };+        let name = String::from_utf8_lossy(e.local_name().as_ref()).to_string();+        let (parent, parent_fill) = *stack.last().unwrap();+        let mut m = parent;+        if let Some(t) = attr(&e, "transform") {+            m = m.mul(&parse_transform(&t));+        }+        let style = attr(&e, "style").unwrap_or_default();+        let fill = attr(&e, "fill").or_else(|| style_prop(&style, "fill"));+        let filled = match fill.as_deref() {+            Some("none") | Some("transparent") => false,+            Some(_) => true,+            None => parent_fill,+        };+        if name == "svg" && !seen_root {+            seen_root = true;+            let vb = attr(&e, "viewBox").map(|v| numbers(&v)).filter(|v| v.len() == 4 && v[2] > 0.0 && v[3] > 0.0);+            let w = attr(&e, "width").and_then(|w| unit_mm(&w));+            let h = attr(&e, "height").and_then(|h| unit_mm(&h));+            if let Some(vb) = &vb {+                m = m.mul(&Mat([1.0, 0.0, 0.0, 1.0, -vb[0], -vb[1]]));+                mm_per_unit = match (w, h) {+                    (Some(w), _) => w / vb[2],+                    (None, Some(h)) => h / vb[3],+                    _ => 25.4 / 96.0,+                };+            } else if let Some(w) = w {+                let raw = attr(&e, "width").and_then(|w| numbers(&w).first().copied()).unwrap_or(0.0);+                if raw > 0.0 {+                    mm_per_unit = w / raw;+                }+            }+        }+        if matches!(name.as_str(), "defs" | "clipPath" | "mask" | "symbol") && !empty {+            defs_depth += 1;+        }+        let tol = tol_mm / mm_per_unit.max(1e-9) / m.scale_factor();+        let num = |k: &str| attr(&e, k).and_then(|v| numbers(&v).first().copied());+        let mut local: Vec<Vec<Pt>> = Vec::new();+        if defs_depth == 0 && filled {+            match name.as_str() {+                "path" => local = path_rings(&attr(&e, "d").unwrap_or_default(), tol),+                "rect" => {+                    let (x, y, w, h) = (num("x").unwrap_or(0.0), num("y").unwrap_or(0.0), num("width").unwrap_or(0.0), num("height").unwrap_or(0.0));+                    let rx = num("rx").or(num("ry")).unwrap_or(0.0).min(w / 2.0);+                    let ry = num("ry").or(num("rx")).unwrap_or(0.0).min(h / 2.0);+                    if w > 0.0 && h > 0.0 {+                        if rx > 0.0 && ry > 0.0 {+                            let d = format!("M{} {} H{} A{rx} {ry} 0 0 1 {} {} V{} A{rx} {ry} 0 0 1 {} {} H{} A{rx} {ry} 0 0 1 {} {} V{} A{rx} {ry} 0 0 1 {} {} Z",+                                x + rx, y, x + w - rx, x + w, y + ry, y + h - ry, x + w - rx, y + h, x + rx, x, y + h - ry, y + ry, x + rx, y);+                            local = path_rings(&d, tol);+                        } else {+                            local.push(vec![(x, y), (x + w, y), (x + w, y + h), (x, y + h)]);+                        }+                    }+                }+                "circle" => {+                    let r = num("r").unwrap_or(0.0);+                    if r > 0.0 {+                        local.push(ellipse_ring(num("cx").unwrap_or(0.0), num("cy").unwrap_or(0.0), r, r, tol));+                    }+                }+                "ellipse" => {+                    let (rx, ry) = (num("rx").unwrap_or(0.0), num("ry").unwrap_or(0.0));+                    if rx > 0.0 && ry > 0.0 {+                        local.push(ellipse_ring(num("cx").unwrap_or(0.0), num("cy").unwrap_or(0.0), rx, ry, tol));+                    }+                }+                "polygon" | "polyline" => {+                    let v = numbers(&attr(&e, "points").unwrap_or_default());+                    let r: Vec<Pt> = v.chunks(2).filter(|c| c.len() == 2).map(|c| (c[0], c[1])).collect();+                    if r.len() >= 3 {+                        local.push(r);+                    }+                }+                "text" | "image" | "use" => skipped.push(format!("<{name}> is not imported (convert text to paths first)")),+                _ => {}+            }+        }+        for r in local {+            rings.push(r.into_iter().map(|p| m.apply(p)).collect::<Vec<_>>());+        }+        if !empty {+            stack.push((m, filled));+        }+    }+    if !seen_root {+        return Err("not an SVG document (no <svg> element)".into());+    }+    Ok(Art { rings, mm_per_unit, skipped })+}++/// Group rings into filled outlines with their holes (by containment depth) and fracture+/// every hole into its outline. Returns simple polygons.+pub fn fill_polygons(rings: &[Vec<Pt>]) -> Vec<Vec<Pt>> {+    let n = rings.len();+    let depth: Vec<usize> = (0..n).map(|i| (0..n).filter(|&j| j != i && area(&rings[j]).abs() > area(&rings[i]).abs() && inside(rings[i][0], &rings[j])).count()).collect();+    let mut out = Vec::new();+    for i in 0..n {+        if depth[i] % 2 == 1 {+            continue;+        }+        let mut outer = rings[i].clone();+        if area(&outer) < 0.0 {+            outer.reverse();+        }+        let mut holes: Vec<Vec<Pt>> = (0..n)+            .filter(|&j| depth[j] == depth[i] + 1 && inside(rings[j][0], &rings[i]))+            .map(|j| {+                let mut h = rings[j].clone();+                if area(&h) > 0.0 {+                    h.reverse();+                }+                h+            })+            .collect();+        holes.sort_by(|a, b| {+            let mx = |r: &Vec<Pt>| r.iter().map(|p| p.0).fold(f64::MIN, f64::max);+            mx(b).partial_cmp(&mx(a)).unwrap_or(std::cmp::Ordering::Equal)+        });+        for h in &holes {+            outer = bridge(&outer, h, &holes);+        }+        out.push(outer);+    }+    out+}++fn segs_cross(a: Pt, b: Pt, c: Pt, d: Pt) -> bool {+    let o = |p: Pt, q: Pt, r: Pt| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0);+    let (d1, d2, d3, d4) = (o(c, d, a), o(c, d, b), o(a, b, c), o(a, b, d));+    ((d1 > 1e-12 && d2 < -1e-12) || (d1 < -1e-12 && d2 > 1e-12)) && ((d3 > 1e-12 && d4 < -1e-12) || (d3 < -1e-12 && d4 > 1e-12))+}++/// Join `hole` into `outer` through the closest mutually visible vertex pair.+fn bridge(outer: &[Pt], hole: &[Pt], all_holes: &[Vec<Pt>]) -> Vec<Pt> {+    let hi = (0..hole.len()).max_by(|&a, &b| hole[a].0.partial_cmp(&hole[b].0).unwrap_or(std::cmp::Ordering::Equal)).unwrap_or(0);+    let m = hole[hi];+    let visible = |p: Pt| -> bool {+        let edges = |r: &[Pt]| (0..r.len()).map(move |i| (r[i], r[(i + 1) % r.len()])).collect::<Vec<_>>();+        edges(outer).iter().chain(all_holes.iter().flat_map(|h| edges(h)).collect::<Vec<_>>().iter()).all(|(a, b)| !segs_cross(m, p, *a, *b))+    };+    let mut order: Vec<usize> = (0..outer.len()).collect();+    order.sort_by(|&a, &b| dist(outer[a], m).partial_cmp(&dist(outer[b], m)).unwrap_or(std::cmp::Ordering::Equal));+    let oi = order.iter().copied().find(|&i| visible(outer[i])).unwrap_or(order[0]);+    let mut r = Vec::with_capacity(outer.len() + hole.len() + 2);+    r.extend_from_slice(&outer[..=oi]);+    for k in 0..=hole.len() {+        r.push(hole[(hi + k) % hole.len()]);+    }+    r.push(outer[oi]);+    r.extend_from_slice(&outer[oi + 1..]);+    r+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn a_square_with_a_hole_becomes_one_fractured_polygon_in_mm() {+        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="10mm" height="10mm" viewBox="0 0 100 100">+            <path d="M0 0 H100 V100 H0 Z M25 25 v50 h50 v-50 z" fill-rule="evenodd"/>+            <g transform="translate(200,0)"><circle cx="10" cy="10" r="5"/></g>+            <rect x="0" y="0" width="5" height="5" fill="none"/>+            <text>hi</text></svg>"#;+        let art = parse_svg(svg, 0.05).unwrap();+        assert!((art.mm_per_unit - 0.1).abs() < 1e-12);+        assert_eq!(art.rings.len(), 3);+        assert!(art.skipped.iter().any(|s| s.contains("text")));+        let polys = fill_polygons(&art.rings);+        assert_eq!(polys.len(), 2, "the square (with its hole joined) and the circle");+        let sq = polys.iter().find(|p| p.len() < 20).unwrap();+        assert!((area(sq).abs() - (10000.0 - 2500.0)).abs() < 1e-6, "{}", area(sq));+        let circle = polys.iter().find(|p| p.len() >= 20).unwrap();+        assert!(circle.iter().all(|p| p.0 > 200.0));+    }++    #[test]+    fn curves_and_arcs_flatten_to_their_ends() {+        let r = path_rings("M0 0 C10 0 10 10 0 10 Q-5 5 0 0 Z", 0.01);+        assert_eq!(r.len(), 1);+        let r = path_rings("M0 0 A5 5 0 0 1 10 0 L10 1 L0 1 Z", 0.01);+        let top = r[0].iter().map(|p| p.1).fold(f64::MAX, f64::min);+        assert!((top + 5.0).abs() < 0.05, "a half circle above the chord reaches y=-5, got {top}");+        assert_eq!(parse_transform("rotate(90)").apply((1.0, 0.0)).1.round(), 1.0);+    }+}
rust/crates/kicad-core/src/sxedit.rsadded+276
@@ -0,0 +1,276 @@+//! A span-keeping s-expression reader for editing KiCad files in place.+//!+//! `model_check::Sx` drops the byte offsets, so an edit through it means re-serialising+//! the whole file in our own formatting. This reader keeps, for every list and atom, the+//! exact byte range it came from, so an edit replaces only the bytes it means to change+//! and the rest of the file stays byte-for-byte what KiCad wrote.++/// One node: a list `( ... )` with children, or an atom (bare or quoted).+#[derive(Clone, Debug, PartialEq)]+pub struct Node {+    /// Byte offset of the first character (`(` or the atom's first byte or `"`).+    pub start: usize,+    /// Byte offset one past the last character.+    pub end: usize,+    /// Unquoted, unescaped value for an atom; None for a list.+    pub atom: Option<String>,+    /// True for a `"quoted"` atom.+    pub quoted: bool,+    pub kids: Vec<Node>,+}++impl Node {+    pub fn is_list(&self) -> bool {+        self.atom.is_none()+    }+    /// The list's tag: its first child when that is a bare atom.+    pub fn tag(&self) -> Option<&str> {+        self.kids.first().filter(|k| !k.quoted).and_then(|k| k.atom.as_deref())+    }+    /// The i-th child as an atom value (0 is the tag).+    pub fn atom(&self, i: usize) -> Option<&str> {+        self.kids.get(i).and_then(|k| k.atom.as_deref())+    }+    pub fn f64_at(&self, i: usize) -> Option<f64> {+        self.atom(i)?.parse().ok()+    }+    /// The first direct child list tagged `tag`.+    pub fn kid(&self, tag: &str) -> Option<&Node> {+        self.kids.iter().find(|k| k.tag() == Some(tag))+    }+    /// Every direct child list tagged `tag`.+    pub fn kids_tagged<'a>(&'a self, tag: &'a str) -> impl Iterator<Item = &'a Node> + 'a {+        self.kids.iter().filter(move |k| k.tag() == Some(tag))+    }+    /// `(tag "value")` -> value.+    pub fn value_of(&self, tag: &str) -> Option<&str> {+        self.kid(tag).and_then(|k| k.atom(1))+    }+    /// The source text of this node.+    pub fn src<'a>(&self, text: &'a str) -> &'a str {+        &text[self.start..self.end]+    }+    /// A bare atom child equal to `word` (e.g. `locked` in older footprints).+    pub fn has_word(&self, word: &str) -> bool {+        self.kids.iter().skip(1).any(|k| !k.quoted && k.atom.as_deref() == Some(word))+    }+    /// Depth-first walk over every list node, self included.+    pub fn walk<'a>(&'a self, f: &mut dyn FnMut(&'a Node)) {+        if self.is_list() {+            f(self);+            for k in &self.kids {+                k.walk(f);+            }+        }+    }+    /// The `(uuid "...")` (or older `(tstamp ...)`) of this list.+    pub fn uuid(&self) -> Option<&str> {+        self.value_of("uuid").or_else(|| self.value_of("tstamp"))+    }+}++/// Parse the first top-level expression of `text`.+pub fn parse(text: &str) -> Result<Node, String> {+    let b = text.as_bytes();+    let mut i = 0;+    skip_ws(b, &mut i);+    if i >= b.len() {+        return Err("empty input".into());+    }+    let n = node(text, b, &mut i, 0)?;+    Ok(n)+}++fn skip_ws(b: &[u8], i: &mut usize) {+    while *i < b.len() && (b[*i] as char).is_ascii_whitespace() {+        *i += 1;+    }+}++fn node(text: &str, b: &[u8], i: &mut usize, depth: usize) -> Result<Node, String> {+    if depth > 400 {+        return Err("nesting too deep".into());+    }+    let start = *i;+    match b[*i] {+        b'(' => {+            *i += 1;+            let mut kids = Vec::new();+            loop {+                skip_ws(b, i);+                if *i >= b.len() {+                    return Err(format!("unclosed list starting at byte {start}"));+                }+                if b[*i] == b')' {+                    *i += 1;+                    return Ok(Node { start, end: *i, atom: None, quoted: false, kids });+                }+                kids.push(node(text, b, i, depth + 1)?);+            }+        }+        b')' => Err(format!("unexpected ) at byte {start}")),+        b'"' => {+            *i += 1;+            let mut s = String::new();+            let mut seg = *i;+            loop {+                if *i >= b.len() {+                    return Err(format!("unterminated string at byte {start}"));+                }+                match b[*i] {+                    b'"' => {+                        s.push_str(&text[seg..*i]);+                        *i += 1;+                        break;+                    }+                    b'\\' if *i + 1 < b.len() => {+                        s.push_str(&text[seg..*i]);+                        let c = b[*i + 1];+                        match c {+                            b'"' => s.push('"'),+                            b'\\' => s.push('\\'),+                            b'n' => s.push('\n'),+                            _ => {+                                s.push('\\');+                                s.push(c as char);+                            }+                        }+                        *i += 2;+                        seg = *i;+                    }+                    _ => *i += 1,+                }+            }+            Ok(Node { start, end: *i, atom: Some(s), quoted: true, kids: Vec::new() })+        }+        _ => {+            while *i < b.len() && !(b[*i] as char).is_ascii_whitespace() && b[*i] != b'(' && b[*i] != b')' {+                *i += 1;+            }+            Ok(Node { start, end: *i, atom: Some(text[start..*i].to_string()), quoted: false, kids: Vec::new() })+        }+    }+}++/// KiCad's string quoting: `"`, `\` and newlines escaped.+pub fn q(s: &str) -> String {+    let mut out = String::with_capacity(s.len() + 2);+    out.push('"');+    for c in s.chars() {+        match c {+            '"' => out.push_str("\\\""),+            '\\' => out.push_str("\\\\"),+            '\n' => out.push_str("\\n"),+            c => out.push(c),+        }+    }+    out.push('"');+    out+}++/// KiCad-style trimmed number (`1.5`, `-0.95`, `0`).+pub fn n(v: f64) -> String {+    crate::pcb::num(v)+}++/// A set of byte-range replacements applied in one pass. Ranges must not overlap.+#[derive(Default, Debug, Clone)]+pub struct Edits {+    rows: Vec<(usize, usize, String)>,+}++impl Edits {+    pub fn new() -> Edits {+        Edits::default()+    }+    pub fn replace(&mut self, start: usize, end: usize, with: impl Into<String>) {+        self.rows.push((start, end, with.into()));+    }+    pub fn replace_node(&mut self, n: &Node, with: impl Into<String>) {+        self.replace(n.start, n.end, with);+    }+    pub fn insert(&mut self, at: usize, with: impl Into<String>) {+        self.rows.push((at, at, with.into()));+    }+    /// Remove a node together with the whitespace before it on its line, so a removed+    /// top-level item leaves no blank line behind.+    pub fn remove_node(&mut self, text: &str, n: &Node) {+        let b = text.as_bytes();+        let mut s = n.start;+        while s > 0 && (b[s - 1] == b' ' || b[s - 1] == b'\t') {+            s -= 1;+        }+        if s > 0 && b[s - 1] == b'\n' {+            s -= 1;+            if s > 0 && b[s - 1] == b'\r' {+                s -= 1;+            }+        }+        self.rows.push((s, n.end, String::new()));+    }+    pub fn is_empty(&self) -> bool {+        self.rows.is_empty()+    }+    pub fn apply(mut self, text: &str) -> Result<String, String> {+        self.rows.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));+        let mut out = String::with_capacity(text.len());+        let mut last = 0;+        for (s, e, w) in &self.rows {+            if *s < last {+                return Err(format!("overlapping edits at byte {s}"));+            }+            out.push_str(&text[last..*s]);+            out.push_str(w);+            last = *e;+        }+        out.push_str(&text[last..]);+        Ok(out)+    }+}++/// Insert `blocks` as new top-level items just before the root's closing paren.+pub fn append_top_level(text: &str, root: &Node, blocks: &[String]) -> String {+    if blocks.is_empty() {+        return text.to_string();+    }+    let close = root.end - 1;+    let mut add = String::new();+    for b in blocks {+        add.push('\t');+        add.push_str(b.trim());+        add.push('\n');+    }+    let head = text[..close].trim_end_matches([' ', '\t']);+    let head = if head.ends_with('\n') { head.to_string() } else { format!("{head}\n") };+    format!("{head}{add}{}", &text[close..])+}++/// Re-indent a one-item block so it reads like KiCad's own output when inserted at the+/// top level (best effort: the parser does not care).+pub fn tidy(block: &str) -> String {+    block.trim().to_string()+}++#[cfg(test)]+mod tests {+    use super::*;++    #[test]+    fn spans_round_trip_and_edits_touch_only_their_bytes() {+        let t = "(kicad_pcb (version 1)\n\t(gr_line (start 0 0) (end 1 2) (layer \"Edge.Cuts\") (uuid \"a\\\"b\"))\n\t(net 1 \"GND\")\n)";+        let root = parse(t).unwrap();+        assert_eq!(root.tag(), Some("kicad_pcb"));+        let line = root.kid("gr_line").unwrap();+        assert_eq!(line.uuid(), Some("a\"b"));+        assert_eq!(line.value_of("layer"), Some("Edge.Cuts"));+        assert_eq!(line.kid("end").unwrap().f64_at(2), Some(2.0));+        let mut e = Edits::new();+        e.remove_node(t, line);+        let out = e.apply(t).unwrap();+        assert_eq!(out, "(kicad_pcb (version 1)\n\t(net 1 \"GND\")\n)");+        let again = append_top_level(&out, &parse(&out).unwrap(), &["(gr_circle (center 1 1) (end 2 1))".into()]);+        assert!(again.ends_with("\t(gr_circle (center 1 1) (end 2 1))\n)"), "{again}");+        assert_eq!(q("a\"b\\c"), "\"a\\\"b\\\\c\"");+        assert!(parse("(a (b)").is_err());+    }+}
rust/crates/kicad-core/src/symedit.rsadded+405
@@ -0,0 +1,405 @@+//! Symbol authoring on `.kicad_sym` libraries (KiCad 10 format): append a symbol built+//! from a pin list (rectangle body sized to the pins and names, fields, optional units),+//! delete one, and read one back with its pins, units and fields. Library edits go through+//! the lossless document model, so the other symbols in the file stay byte-identical.++use std::collections::BTreeMap;++use serde_json::{json, Value};++use crate::pcb::num;+use crate::schematic::{flatten_lib_symbol, parse_node, pretty, quote, Document};++pub const SYM_VERSION: &str = "20251024";+pub const PIN_TYPES: &[&str] = &["input", "output", "bidirectional", "tri_state", "passive", "free", "unspecified", "power_in", "power_out", "open_collector", "open_emitter", "no_connect"];+pub const PIN_SHAPES: &[&str] = &["line", "inverted", "clock", "inverted_clock", "input_low", "clock_low", "output_low", "edge_clock_high", "non_logic"];+const GRID: f64 = 2.54;++pub fn empty_library() -> String {+    format!("(kicad_symbol_lib\n\t(version {SYM_VERSION})\n\t(generator \"adom_kicad_bridge\")\n\t(generator_version \"10.0\")\n)\n")+}++#[derive(Clone, Debug)]+struct Pin {+    number: String,+    name: String,+    kind: String,+    shape: String,+    side: String,+    order: f64,+    length: f64,+    unit: u32,+    hidden: bool,+    at: Option<(f64, f64, f64)>,+}++fn pin_from_json(v: &Value, i: usize) -> Result<Pin, String> {+    let number = match v.get("number") {+        Some(Value::String(s)) if !s.is_empty() => s.clone(),+        Some(Value::Number(n)) => n.to_string(),+        _ => return Err(format!("pin {}: number is required", i + 1)),+    };+    let name = v.get("name").and_then(Value::as_str).unwrap_or("~").to_string();+    let kind = v.get("type").and_then(Value::as_str).unwrap_or("passive").to_string();+    if !PIN_TYPES.contains(&kind.as_str()) {+        return Err(format!("pin {number}: type must be one of {PIN_TYPES:?}"));+    }+    let shape = v.get("shape").and_then(Value::as_str).unwrap_or("line").to_string();+    if !PIN_SHAPES.contains(&shape.as_str()) {+        return Err(format!("pin {number}: shape must be one of {PIN_SHAPES:?}"));+    }+    let side = v.get("side").and_then(Value::as_str).unwrap_or(match kind.as_str() {+        "output" | "power_out" | "tri_state" | "open_collector" | "open_emitter" => "right",+        _ => "left",+    }).to_string();+    if !["left", "right", "top", "bottom"].contains(&side.as_str()) {+        return Err(format!("pin {number}: side must be left, right, top or bottom"));+    }+    let at = match v.get("at") {+        Some(Value::Array(a)) if a.len() >= 2 => Some((a[0].as_f64().unwrap_or(0.0), a[1].as_f64().unwrap_or(0.0), a.get(2).and_then(Value::as_f64).unwrap_or(0.0))),+        _ => None,+    };+    Ok(Pin {+        number,+        name,+        kind,+        shape,+        side,+        order: v.get("position").and_then(Value::as_f64).unwrap_or(i as f64),+        length: v.get("length").and_then(Value::as_f64).unwrap_or(GRID),+        unit: v.get("unit").and_then(Value::as_u64).unwrap_or(1).max(1) as u32,+        hidden: v.get("hidden").and_then(Value::as_bool).unwrap_or(false),+        at,+    })+}++fn up(v: f64) -> f64 {+    (v / GRID - 1e-9).ceil() * GRID+}++fn font() -> &'static str {+    "(effects (font (size 1.27 1.27)))"+}++fn prop(name: &str, value: &str, at: (f64, f64), hide: bool) -> String {+    format!("(property {} {} (at {} {} 0) (show_name no) (do_not_autoplace no){} {})", quote(name), quote(value), num(at.0), num(at.1), if hide { " (hide yes)" } else { "" }, font())+}++/// One unit's body and pins. Returns (rectangle and pin texts, body top y, body bottom y).+fn unit_block(pins: &[&Pin]) -> (Vec<String>, f64, f64) {+    let side = |s: &str| {+        let mut v: Vec<&&Pin> = pins.iter().filter(|p| p.side == s && p.at.is_none()).collect();+        v.sort_by(|a, b| a.order.partial_cmp(&b.order).unwrap());+        v+    };+    let (l, r, t, b) = (side("left"), side("right"), side("top"), side("bottom"));+    let chars = |v: &[&&Pin]| v.iter().map(|p| if p.name == "~" { 0 } else { p.name.chars().count() }).max().unwrap_or(0) as f64;+    // Names sit inside the body: ~1 mm per character at 1.27 mm text.+    let need_w = (chars(&l) + chars(&r)) * 1.0 + 2.54;+    let need_w = need_w.max((t.len().max(b.len()) as f64 + 1.0) * GRID).max(5.08);+    let half_w = up(need_w / 2.0);+    let rows = l.len().max(r.len()).max(1) as f64;+    let top_y = ((rows - 1.0) / 2.0).floor() * GRID;+    let need_h_chars = (chars(&t) + chars(&b)) * 1.0;+    let mut body_top = top_y + GRID;+    let mut body_bot = top_y - (rows - 1.0) * GRID - GRID;+    if body_top - body_bot < need_h_chars + 2.54 {+        let extra = up((need_h_chars + 2.54 - (body_top - body_bot)) / 2.0);+        body_top += extra;+        body_bot -= extra;+    }+    let mut out = vec![format!("(rectangle (start {} {}) (end {} {}) (stroke (width 0.254) (type default)) (fill (type background)))", num(-half_w), num(body_top), num(half_w), num(body_bot))];+    let pin_text = |p: &Pin, x: f64, y: f64, ang: f64| {+        format!(+            "(pin {} {} (at {} {} {}) (length {}){} (name {} {}) (number {} {}))",+            p.kind, p.shape, num(x), num(y), num(ang), num(p.length), if p.hidden { " (hide yes)" } else { "" }, quote(&p.name), font(), quote(&p.number), font()+        )+    };+    for (i, p) in l.iter().enumerate() {+        out.push(pin_text(p, -half_w - p.length, top_y - i as f64 * GRID, 0.0));+    }+    for (i, p) in r.iter().enumerate() {+        out.push(pin_text(p, half_w + p.length, top_y - i as f64 * GRID, 180.0));+    }+    let x0 = -(((t.len() as f64) - 1.0) / 2.0).floor() * GRID;+    for (i, p) in t.iter().enumerate() {+        out.push(pin_text(p, x0 + i as f64 * GRID, body_top + p.length, 270.0));+    }+    let x0 = -(((b.len() as f64) - 1.0) / 2.0).floor() * GRID;+    for (i, p) in b.iter().enumerate() {+        out.push(pin_text(p, x0 + i as f64 * GRID, body_bot - p.length, 90.0));+    }+    for p in pins.iter().filter(|p| p.at.is_some()) {+        let (x, y, a) = p.at.unwrap();+        out.push(pin_text(p, x, y, a));+    }+    (out, body_top, body_bot)+}++/// The `(symbol ...)` text for a new symbol from the verb's args.+pub fn build_symbol(name: &str, args: &Value) -> Result<String, String> {+    if name.trim().is_empty() || name.contains([':', '"', '/', '\\']) {+        return Err("name must be a plain symbol name (no : / \\ or quotes)".into());+    }+    let pins_v = args.get("pins").and_then(Value::as_array).ok_or("pins is required: a list of {number, name, type, side}")?;+    let pins: Vec<Pin> = pins_v.iter().enumerate().map(|(i, p)| pin_from_json(p, i)).collect::<Result<_, _>>()?;+    let mut seen: BTreeMap<(u32, &str), usize> = BTreeMap::new();+    for p in &pins {+        *seen.entry((p.unit, p.number.as_str())).or_default() += 1;+    }+    if let Some(((u, n), _)) = seen.iter().find(|(_, c)| **c > 1) {+        return Err(format!("pin number {n} appears twice in unit {u}"));+    }+    let units = pins.iter().map(|p| p.unit).max().unwrap_or(1);+    let reference = args.get("reference").and_then(Value::as_str).unwrap_or("U");+    let value = args.get("value").and_then(Value::as_str).unwrap_or(name);+    let mut bodies: Vec<String> = Vec::new();+    let (mut top, mut bot) = (0.0f64, 0.0f64);+    for u in 1..=units {+        let these: Vec<&Pin> = pins.iter().filter(|p| p.unit == u).collect();+        if these.is_empty() {+            return Err(format!("unit {u} has no pins (units must be numbered 1..{units} without gaps)"));+        }+        let (items, t, b) = unit_block(&these);+        top = top.max(t);+        bot = bot.min(b);+        if units == 1 {+            bodies.push(format!("(symbol {} {})", quote(&format!("{name}_0_1")), items[0]));+            bodies.push(format!("(symbol {} {})", quote(&format!("{name}_1_1")), items[1..].join(" ")));+        } else {+            bodies.push(format!("(symbol {} {})", quote(&format!("{name}_{u}_1")), items.join(" ")));+        }+    }+    let mut parts: Vec<String> = Vec::new();+    if args.get("hidePinNumbers").and_then(Value::as_bool).unwrap_or(false) {+        parts.push("(pin_numbers (hide yes))".into());+    }+    parts.push(format!("(pin_names (offset {}))", num(args.get("pinNameOffset").and_then(Value::as_f64).unwrap_or(1.016))));+    parts.push("(exclude_from_sim no) (in_bom yes) (on_board yes) (in_pos_files yes) (duplicate_pin_numbers_are_jumpers no)".into());+    let half_hint = 0.0;+    parts.push(prop("Reference", reference, (half_hint, top + 1.27), false));+    parts.push(prop("Value", value, (half_hint, bot - 1.27), false));+    parts.push(prop("Footprint", args.get("footprint").and_then(Value::as_str).unwrap_or(""), (0.0, bot - 3.81), true));+    parts.push(prop("Datasheet", args.get("datasheet").and_then(Value::as_str).unwrap_or(""), (0.0, 0.0), true));+    parts.push(prop("Description", args.get("description").and_then(Value::as_str).unwrap_or(""), (0.0, 0.0), true));+    if let Some(k) = args.get("keywords") {+        let k = match k {+            Value::Array(a) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "),+            Value::String(s) => s.clone(),+            _ => String::new(),+        };+        parts.push(prop("ki_keywords", &k, (0.0, 0.0), true));+    }+    if let Some(f) = args.get("fpFilters") {+        let f = match f {+            Value::Array(a) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "),+            Value::String(s) => s.clone(),+            _ => String::new(),+        };+        parts.push(prop("ki_fp_filters", &f, (0.0, 0.0), true));+    }+    if let Some(fields) = args.get("fields").and_then(Value::as_object) {+        for (k, v) in fields {+            if ["Reference", "Value", "Footprint", "Datasheet", "Description"].contains(&k.as_str()) {+                continue;+            }+            parts.push(prop(k, v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()).as_str(), (0.0, 0.0), true));+        }+    }+    parts.extend(bodies);+    parts.push("(embedded_fonts no)".into());+    Ok(format!("(symbol {} {})", quote(name), parts.join(" ")))+}++fn top_symbols(doc: &Document) -> Vec<(usize, String)> {+    doc.root.children().iter().enumerate().filter(|(_, c)| c.head() == Some("symbol")).filter_map(|(i, c)| c.arg(0).map(|n| (i, n))).collect()+}++/// Append (or with `overwrite`, replace) a symbol in library text. Returns the new text.+pub fn add_symbol(lib_text: &str, name: &str, args: &Value, overwrite: bool) -> Result<(String, bool), String> {+    let mut doc = Document::parse(lib_text)?;+    if doc.root.head() != Some("kicad_symbol_lib") {+        return Err("not a symbol library (top-level list is not kicad_symbol_lib)".into());+    }+    let block = build_symbol(name, args)?;+    let mut node = parse_node(&block)?;+    pretty(&mut node, 1);+    let existing = top_symbols(&doc).into_iter().find(|(_, n)| n == name).map(|(i, _)| i);+    let replaced = existing.is_some();+    let kids = doc.root.children_mut().unwrap();+    match existing {+        Some(_) if !overwrite => return Err(format!("symbol {name} already exists in the library; pass overwrite:true to replace it")),+        Some(i) => {+            node.lead = kids[i].lead.clone();+            kids[i] = node;+        }+        None => {+            let at = kids.iter().rposition(|c| c.head() == Some("symbol")).map(|i| i + 1).unwrap_or(kids.len());+            kids.insert(at, node);+        }+    }+    let out = doc.to_text();+    crate::lint::check_symbol_lib_structure(&out)?;+    if !crate::libraries::list_symbols_in_text(&out).iter().any(|n| n == name) {+        return Err("the new symbol does not read back".into());+    }+    Ok((out, replaced))+}++/// Remove a top-level symbol. Refuses when another symbol extends it unless `force`.+pub fn delete_symbol(lib_text: &str, name: &str, force: bool) -> Result<(String, Vec<String>), String> {+    let mut doc = Document::parse(lib_text)?;+    if doc.root.head() != Some("kicad_symbol_lib") {+        return Err("not a symbol library".into());+    }+    let syms = top_symbols(&doc);+    let Some((idx, _)) = syms.iter().find(|(_, n)| n == name) else {+        let names: Vec<&str> = syms.iter().map(|(_, n)| n.as_str()).take(40).collect();+        return Err(format!("no symbol {name} in the library (it has: {})", names.join(", ")));+    };+    let children: Vec<String> = doc.root.children().iter().filter(|c| c.head() == Some("symbol") && c.child_value("extends").as_deref() == Some(name)).filter_map(|c| c.arg(0)).collect();+    if !children.is_empty() && !force {+        return Err(format!("{} extend {name}: delete them first or pass force:true (they will no longer load)", children.join(", ")));+    }+    doc.root.children_mut().unwrap().remove(*idx);+    let out = doc.to_text();+    crate::lint::check_symbol_lib_structure(&out)?;+    Ok((out, children))+}++/// Pins, units, fields and flags of one symbol (derived symbols resolved through `extends`).+pub fn symbol_info(lib_text: &str, name: &str) -> Result<Value, String> {+    let doc = Document::parse(lib_text)?;+    let raw = doc.root.children().iter().find(|c| c.head() == Some("symbol") && c.arg(0).as_deref() == Some(name)).ok_or_else(|| {+        let names: Vec<String> = top_symbols(&doc).into_iter().map(|(_, n)| n).take(40).collect();+        format!("no symbol {name} in the library (it has: {})", names.join(", "))+    })?;+    let extends = raw.child_value("extends");+    let flat = flatten_lib_symbol(lib_text, name)?;+    let mut fields = serde_json::Map::new();+    for p in flat.children().iter().filter(|c| c.head() == Some("property")) {+        if let (Some(k), Some(v)) = (p.arg(0), p.arg(1)) {+            fields.insert(k, json!(v));+        }+    }+    let mut pins = Vec::new();+    let mut units: std::collections::BTreeSet<u32> = Default::default();+    let mut styles: std::collections::BTreeSet<u32> = Default::default();+    for sub in flat.children().iter().filter(|c| c.head() == Some("symbol")) {+        let sname = sub.arg(0).unwrap_or_default();+        let mut it = sname.rsplitn(3, '_');+        let style: u32 = it.next().and_then(|s| s.parse().ok()).unwrap_or(1);+        let unit: u32 = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);+        if unit > 0 {+            units.insert(unit);+        }+        styles.insert(style);+        for p in sub.children().iter().filter(|c| c.head() == Some("pin")) {+            let at = p.child("at");+            pins.push(json!({+                "number": p.child("number").and_then(|n| n.arg(0)),+                "name": p.child("name").and_then(|n| n.arg(0)),+                "type": p.arg(0), "shape": p.arg(1),+                "at": at.map(|a| json!([a.num(0), a.num(1)])), "rotation": at.and_then(|a| a.num(2)),+                "length": p.child("length").and_then(|l| l.num(0)),+                "unit": unit, "bodyStyle": style,+                "hidden": p.child("hide").map(|h| h.arg(0).as_deref() != Some("no")).unwrap_or(false) || p.children().iter().any(|c| c.raw() == Some("hide")),+            }));+        }+    }+    let flag = |k: &str| flat.child_value(k).map(|v| v == "yes");+    Ok(json!({+        "name": name,+        "extends": extends,+        "power": flat.child("power").is_some(),+        "fields": fields,+        "unitCount": units.len().max(1),+        "bodyStyles": styles.len().max(1),+        "pinCount": pins.len(),+        "pins": pins,+        "inBom": flag("in_bom"), "onBoard": flag("on_board"), "excludeFromSim": flag("exclude_from_sim"),+    }))+}++#[cfg(test)]+mod tests {+    use super::*;++    fn regulator() -> Value {+        json!({+            "reference": "U", "value": "LM1117", "footprint": "Package_TO_SOT_SMD:SOT-223-3_TabPin2", "description": "LDO 800 mA",+            "keywords": ["ldo", "regulator"], "fields": {"MPN": "LM1117IMP-3.3", "LCSC": "C6186"},+            "pins": [{"number": 3, "name": "VI", "type": "power_in", "side": "left"}, {"number": 2, "name": "VO", "type": "power_out"}, {"number": 1, "name": "GND", "type": "power_in", "side": "bottom"}]+        })+    }++    #[test]+    fn add_to_an_empty_library_and_read_back() {+        let (lib, replaced) = add_symbol(&empty_library(), "LM1117", &regulator(), false).unwrap();+        assert!(!replaced);+        crate::lint::check_symbol_lib_structure(&lib).unwrap();+        let inv = crate::lint::parse_kicad_sym(&lib);+        assert_eq!(inv["symbolCount"], 1);+        assert_eq!(inv["symbols"][0]["pinCount"], 3);+        assert_eq!(inv["fileVersion"], 20251024);+        let i = symbol_info(&lib, "LM1117").unwrap();+        assert_eq!(i["fields"]["MPN"], "LM1117IMP-3.3");+        assert_eq!(i["fields"]["ki_keywords"], "ldo regulator");+        assert_eq!(i["pinCount"], 3);+        let vo = i["pins"].as_array().unwrap().iter().find(|p| p["number"] == "2").unwrap();+        assert_eq!(vo["type"], "power_out");+        assert_eq!(vo["rotation"], 180.0, "a right-side pin points left into the body");+        let gnd = i["pins"].as_array().unwrap().iter().find(|p| p["number"] == "1").unwrap();+        assert_eq!(gnd["rotation"], 90.0);+        // every pin end sits on the 2.54 mm grid+        for p in i["pins"].as_array().unwrap() {+            for c in p["at"].as_array().unwrap() {+                let v = c.as_f64().unwrap() / 2.54;+                assert!((v - v.round()).abs() < 1e-6, "{p}");+            }+        }+        // a schematic can place it: the bridge's flattener reads it+        crate::schematic::flatten_lib_symbol(&lib, "LM1117").unwrap();+        assert!(add_symbol(&lib, "LM1117", &regulator(), false).unwrap_err().contains("overwrite"));+        let (lib2, replaced) = add_symbol(&lib, "LM1117", &regulator(), true).unwrap();+        assert!(replaced);+        assert_eq!(crate::libraries::list_symbols_in_text(&lib2), vec!["LM1117"]);+    }++    #[test]+    fn multi_unit_and_validation() {+        let args = json!({"reference": "U", "pins": [+            {"number": 1, "name": "OUTA", "type": "output", "unit": 1}, {"number": 2, "name": "-INA", "type": "input", "unit": 1}, {"number": 3, "name": "+INA", "type": "input", "unit": 1},+            {"number": 7, "name": "OUTB", "type": "output", "unit": 2}, {"number": 6, "name": "-INB", "type": "input", "unit": 2}, {"number": 5, "name": "+INB", "type": "input", "unit": 2},+            {"number": 8, "name": "V+", "type": "power_in", "unit": 3, "side": "top"}, {"number": 4, "name": "V-", "type": "power_in", "unit": 3, "side": "bottom"}]});+        let (lib, _) = add_symbol(&empty_library(), "LM358", &args, false).unwrap();+        let i = symbol_info(&lib, "LM358").unwrap();+        assert_eq!(i["unitCount"], 3);+        assert_eq!(i["pinCount"], 8);+        assert!(build_symbol("X", &json!({"pins": [{"number": 1}, {"number": 1}]})).unwrap_err().contains("twice"));+        assert!(build_symbol("X", &json!({"pins": [{"number": 1, "type": "weird"}]})).is_err());+        assert!(build_symbol("X", &json!({"pins": [{"number": 1, "unit": 2}]})).unwrap_err().contains("unit 1 has no pins"));+        assert!(build_symbol("A:B", &json!({"pins": [{"number": 1}]})).is_err());+    }++    #[test]+    fn delete_keeps_the_other_symbols_byte_identical_and_guards_extends() {+        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../pcm/src/adom-sample-lib/symbols/Adom_Sample.kicad_sym");+        let text = std::fs::read_to_string(path).unwrap();+        let (out, _) = delete_symbol(&text, "AdomSample_C", false).unwrap();+        let names = crate::libraries::list_symbols_in_text(&out);+        assert_eq!(names.len(), 9);+        assert!(!names.contains(&"AdomSample_C".to_string()));+        let r_block = crate::libraries::extract_top_level_symbols(&text).unwrap().into_iter().find(|(n, _)| n == "AdomSample_R").unwrap().1;+        assert!(out.contains(&r_block), "untouched symbols stay byte-identical");+        assert!(delete_symbol(&text, "Nope", false).unwrap_err().contains("AdomSample_R"));+        let derived = text.replacen("(kicad_symbol_lib", "(kicad_symbol_lib\n\t(symbol \"R_alias\" (extends \"AdomSample_R\") (property \"Value\" \"R2\" (at 0 0 0)))", 1);+        assert!(delete_symbol(&derived, "AdomSample_R", false).unwrap_err().contains("R_alias"));+        let (_, orphans) = delete_symbol(&derived, "AdomSample_R", true).unwrap();+        assert_eq!(orphans, vec!["R_alias"]);+        let i = symbol_info(&derived, "R_alias").unwrap();+        assert_eq!(i["extends"], "AdomSample_R");+        assert_eq!(i["fields"]["Value"], "R2");+        assert_eq!(i["pinCount"], 2, "pins resolved through extends");+    }+}
rust/crates/kicad-core/tests/board_edit_fixture.rsadded+527
@@ -0,0 +1,527 @@+//! Board and footprint edits on the repository's KiCad 10 boards: the blank template+//! (`templates/blank-board.kicad_pcb`) and the four-layer ESC G431 placement fixture+//! (`demo/placement/esc-g431-unrouted.kicad_pcb`, 149 footprints, a stack-up, inner planes).+//! Every edit is read back through the bridge's own board parser (`pcb::parse_pcb_text`)+//! and, when `service-kicad` is on PATH, through KiCad 10 itself (a DRC run must load it).++use std::path::{Path, PathBuf};+use std::process::Command;++use serde_json::{json, Value};++use kicad_core::board_edit::{self as be, Change};+use kicad_core::pcb;+use kicad_core::schematic::new_uuid;+use kicad_core::sxedit;++fn repo_file(rel: &str) -> PathBuf {+    Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").join(rel)+}++fn blank() -> String {+    std::fs::read_to_string(repo_file("templates/blank-board.kicad_pcb")).unwrap()+}++fn esc() -> String {+    std::fs::read_to_string(repo_file("demo/placement/esc-g431-unrouted.kicad_pcb")).unwrap()+}++fn parse(t: &str) -> pcb::Board {+    pcb::parse_pcb_text(t, "t.kicad_pcb").unwrap()+}++fn temp_dir(tag: &str) -> PathBuf {+    let d = std::env::temp_dir().join(format!("kicad-core-board-edit-{tag}-{}-{}", std::process::id(), new_uuid()));+    std::fs::create_dir_all(&d).unwrap();+    d+}++const R0603: &str = "(footprint \"R_0603_1608Metric\"\n\t(version 20241229)\n\t(generator \"pcbnew\")\n\t(generator_version \"9.0\")\n\t(layer \"F.Cu\")\n\t(descr \"Resistor SMD 0603\")\n\t(property \"Reference\" \"REF**\" (at 0 -1.43 0) (layer \"F.SilkS\") (uuid \"8a2c3b4e-0000-4000-8000-000000000001\") (effects (font (size 1 1) (thickness 0.15))))\n\t(property \"Value\" \"R_0603_1608Metric\" (at 0 1.43 0) (layer \"F.Fab\") (uuid \"8a2c3b4e-0000-4000-8000-000000000002\") (effects (font (size 1 1) (thickness 0.15))))\n\t(attr smd)\n\t(fp_line (start -0.237258 -0.5225) (end 0.237258 -0.5225) (stroke (width 0.12) (type solid)) (layer \"F.SilkS\") (uuid \"8a2c3b4e-0000-4000-8000-000000000003\"))\n\t(fp_rect (start -1.48 -0.73) (end 1.48 0.73) (stroke (width 0.05) (type solid)) (fill no) (layer \"F.CrtYd\") (uuid \"8a2c3b4e-0000-4000-8000-000000000004\"))\n\t(pad \"1\" smd roundrect (at -0.825 0) (size 0.8 0.95) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"8a2c3b4e-0000-4000-8000-000000000005\"))\n\t(pad \"2\" smd roundrect (at 0.825 0) (size 0.8 0.95) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (uuid \"8a2c3b4e-0000-4000-8000-000000000006\"))\n\t(embedded_fonts no)\n\t(model \"${KICAD10_3DMODEL_DIR}/Resistor_SMD.3dshapes/R_0603_1608Metric.step\" (offset (xyz 0 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 0)))\n)\n";++fn service_kicad() -> Option<PathBuf> {+    let path = std::env::var_os("PATH")?;+    std::env::split_paths(&path).map(|d| d.join("service-kicad")).find(|p| p.is_file())+}++/// KiCad 10 loads the board (a DRC report comes back); None when service-kicad is absent.+fn kicad_loads(tag: &str, text: &str) -> Option<Value> {+    let svc = service_kicad()?;+    let dir = temp_dir(tag);+    let path = dir.join(format!("{tag}.kicad_pcb"));+    std::fs::write(&path, text).unwrap();+    let report = dir.join("drc.json");+    let out = Command::new(&svc).args(["pcb", "drc"]).arg(&path).args(["--format", "json", "--out"]).arg(&report).output().ok()?;+    if !report.is_file() {+        let msg = format!("{}{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout));+        // the service being unreachable is not a failure of the edit+        if msg.to_lowercase().contains("connect") || msg.to_lowercase().contains("unavailable") || msg.to_lowercase().contains("timed out") {+            eprintln!("service-kicad unreachable; skipping KiCad load check for {tag}: {msg}");+            return None;+        }+        panic!("KiCad did not load the edited board {tag}: {msg}");+    }+    Some(serde_json::from_str(&std::fs::read_to_string(&report).unwrap()).unwrap())+}++#[test]+fn outline_rectangle_rounded_and_polygon_on_the_blank_board() {+    let t = blank();+    let c = be::outline(&t, &json!({"width": 50, "height": 30, "x": 100, "y": 80})).unwrap();+    let b = parse(&c.candidate);+    let d = b.dimensions.unwrap();+    assert_eq!((d.min_x, d.min_y, d.max_x, d.max_y), (100.0, 80.0, 150.0, 110.0));+    assert_eq!(c.create.len(), 1);+    // a second outline is refused, a replacement removes the first+    assert_eq!(be::outline(&c.candidate, &json!({"width": 10, "height": 10})).unwrap_err().code, "outline_exists");+    let r = be::outline(&c.candidate, &json!({"shape": "rounded", "width": 40, "height": 20, "radius": 3, "x": 10, "y": 10, "replace": true})).unwrap();+    assert_eq!(r.remove.len(), 1);+    assert_eq!(r.create.len(), 8, "four lines and four true arcs");+    let d = parse(&r.candidate).dimensions.unwrap();+    assert_eq!((d.min_x, d.min_y, d.max_x, d.max_y), (10.0, 10.0, 50.0, 30.0));+    assert!(r.candidate.contains("(gr_arc (start 10 13) (mid"), "{}", &r.candidate[r.candidate.len() - 900..]);+    assert!(!r.candidate.contains("(gr_rect"));+    let p = be::outline(&t, &json!({"points": [[0, 0], [30, 0], [30, 20], [15, 28], [0, 20]]})).unwrap();+    let d = parse(&p.candidate).dimensions.unwrap();+    assert_eq!((d.max_x, d.max_y), (30.0, 28.0));+    assert!(be::outline(&t, &json!({"shape": "rounded", "width": 10, "height": 10, "radius": 6})).is_err());+    if let Some(rep) = kicad_loads("outline", &r.candidate) {+        // a closed outline: no "board has malformed outline" finding+        let bad = rep["violations"].as_array().unwrap().iter().any(|v| v["type"] == "invalid_outline");+        assert!(!bad, "KiCad finds the rounded outline malformed: {rep}");+    }+}++#[test]+fn layer_count_grows_with_the_stackup_and_refuses_to_drop_used_planes() {+    let t = blank();+    let c = be::set_layer_count(&t, &json!({"count": 4})).unwrap();+    assert_eq!(pcb::copper_layers(&parse(&c.candidate)), vec!["F.Cu", "In1.Cu", "In2.Cu", "B.Cu"]);+    assert!(c.candidate.contains("(4 \"In1.Cu\" signal)") && c.candidate.contains("(6 \"In2.Cu\" signal)"));+    let back = be::set_layer_count(&c.candidate, &json!({"count": 2})).unwrap();+    assert_eq!(pcb::copper_layers(&parse(&back.candidate)), vec!["F.Cu", "B.Cu"]);+    assert!(be::set_layer_count(&t, &json!({"count": 3})).is_err());++    let e = esc();+    let six = be::set_layer_count(&e, &json!({"count": 6})).unwrap();+    assert_eq!(pcb::copper_layers(&parse(&six.candidate)).len(), 6);+    let root = sxedit::parse(&six.candidate).unwrap();+    let stack = root.kid("setup").unwrap().kid("stackup").unwrap();+    let names: Vec<&str> = stack.kids_tagged("layer").filter_map(|l| l.atom(1)).collect();+    let cu: Vec<&&str> = names.iter().filter(|n| n.ends_with(".Cu")).collect();+    assert_eq!(cu, vec![&"F.Cu", &"In1.Cu", &"In2.Cu", &"In3.Cu", &"In4.Cu", &"B.Cu"]);+    assert_eq!(names.iter().filter(|n| n.starts_with("dielectric")).count(), 5);+    assert_eq!(six.report["stackup"]["rewritten"], true);+    // the ESC's inner layers carry planes: dropping them is refused with the counts+    let err = be::set_layer_count(&e, &json!({"count": 2})).unwrap_err();+    assert_eq!(err.code, "layers_in_use");+    assert!(err.detail["itemsPerLayer"]["In1.Cu"].as_u64().unwrap() > 0);+    if let Some(rep) = kicad_loads("six-layers", &six.candidate) {+        assert!(rep.get("kicad_version").is_some());+    }+}++#[test]+fn delete_graphics_by_filter_and_by_uuid() {+    let e = esc();+    let edge = be::delete_graphics(&e, &json!({"layer": "Edge.Cuts"})).unwrap();+    assert!(edge.report["count"].as_u64().unwrap() >= 1);+    assert!(parse(&edge.candidate).dimensions.is_none(), "every Edge.Cuts graphic is gone");+    assert_eq!(edge.remove.len() as u64, edge.report["count"].as_u64().unwrap());+    let one = edge.remove[0].clone();+    let by_id = be::delete_graphics(&e, &json!({"uuids": [one]})).unwrap();+    assert_eq!(by_id.report["count"], 1);+    assert_eq!(be::delete_graphics(&e, &json!({"uuids": ["nope"]})).unwrap_err().code, "unknown_ids");+    assert_eq!(be::delete_graphics(&e, &json!({})).unwrap_err().code, "missing_arg");+    assert_eq!(be::delete_graphics(&e, &json!({"type": "blob"})).unwrap_err().code, "invalid_args");+}++#[test]+fn mounting_holes_npth_generated_and_plated_on_a_net() {+    let e = esc();+    let h = be::add_mounting_hole(&e, None, &json!({"x": 106, "y": 60, "drill": 3.2})).unwrap();+    let b = parse(&h.candidate);+    let fp = b.footprints.iter().find(|f| f.reference == h.report["ref"].as_str().unwrap()).unwrap();+    assert_eq!(fp.pads[0].kind, "np_thru_hole");+    assert!(!h.copper);+    let p = be::add_mounting_hole(&e, None, &json!({"x": 166, "y": 60, "drill": 3.2, "plated": true, "padDiameter": 6, "net": "GND", "reference": "H9"})).unwrap();+    let b = parse(&p.candidate);+    let fp = b.footprints.iter().find(|f| f.reference == "H9").unwrap();+    assert_eq!(fp.pads[0].kind, "thru_hole");+    assert_eq!(fp.pads[0].net_name.as_deref(), Some("GND"));+    assert_eq!(fp.pads[0].width, Some(6.0));+    assert!(p.copper);+    assert_eq!(be::add_mounting_hole(&e, None, &json!({"x": 1, "y": 1, "drill": 3, "net": "NOPE"})).unwrap_err().code, "unknown_net");+    // a stock library footprint, when the caller found one+    let lib = "(footprint \"MountingHole_3.2mm_M3\" (layer \"F.Cu\") (property \"Reference\" \"REF**\" (at 0 -4.2 0) (layer \"F.SilkS\") (effects (font (size 1 1)))) (property \"Value\" \"MountingHole_3.2mm_M3\" (at 0 4.2 0) (layer \"F.Fab\") (effects (font (size 1 1)))) (attr exclude_from_pos_files exclude_from_bom) (pad \"\" np_thru_hole circle (at 0 0) (size 3.2 3.2) (drill 3.2) (layers \"*.Cu\" \"*.Mask\")))";+    let s = be::add_mounting_hole(&e, Some((lib, "MountingHole:MountingHole_3.2mm_M3")), &json!({"x": 106, "y": 130, "drill": 3.2})).unwrap();+    assert_eq!(s.report["footprint"], "MountingHole:MountingHole_3.2mm_M3");+    if let Some(rep) = kicad_loads("holes", &p.candidate) {+        assert!(rep.get("kicad_version").is_some());+    }+}++#[test]+fn svg_logo_lands_as_filled_polygons_scaled_to_width() {+    let t = blank();+    let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 100"><path d="M0 0 H200 V100 H0 Z M50 25 h100 v50 h-100 z"/></svg>"#;+    let c = be::import_svg(&t, svg, &json!({"x": 10, "y": 20, "width": 20, "layer": "F.SilkS"})).unwrap();+    assert_eq!(c.report["polygons"], 1);+    assert_eq!(c.report["sizeMm"], json!([20.0, 10.0]));+    assert!(c.candidate.contains("(fill yes) (layer \"F.SilkS\")"));+    let back = be::import_svg(&t, svg, &json!({"x": 10, "y": 20, "width": 20, "layer": "B.SilkS"})).unwrap();+    assert_eq!(back.report["mirrored"], true);+    assert_eq!(be::import_svg(&t, svg, &json!({"x": 0, "y": 0, "layer": "Edge.Cuts"})).unwrap_err().code, "invalid_layer");+    assert_eq!(be::import_svg(&t, "<svg/>", &json!({"x": 0, "y": 0})).unwrap_err().code, "svg_empty");+    if let Some(rep) = kicad_loads("svg", &c.candidate) {+        assert!(rep.get("kicad_version").is_some());+    }+}++#[test]+fn add_footprint_from_a_library_as_an_array_and_as_a_duplicate() {+    let e = esc();+    let c = be::add_footprint(&e, Some((R0603, "Resistor_SMD:R_0603_1608Metric")), &json!({"x": 180, "y": 70, "reference": "R", "value": "10k", "count": 3, "pitch": [3, 0]})).unwrap();+    let b = parse(&c.candidate);+    let added: Vec<&pcb::Footprint> = b.footprints.iter().filter(|f| f.footprint == "Resistor_SMD:R_0603_1608Metric").collect();+    assert_eq!(added.len(), 3);+    let xs: Vec<f64> = added.iter().map(|f| f.position.as_ref().unwrap().x).collect();+    assert_eq!(xs, vec![180.0, 183.0, 186.0]);+    assert!(added.iter().all(|f| f.value == "10k" && f.reference.starts_with('R')));+    let refs: Vec<&str> = added.iter().map(|f| f.reference.as_str()).collect();+    let before = parse(&e);+    assert!(refs.iter().all(|r| !before.footprints.iter().any(|f| f.reference == *r)), "fresh references {refs:?}");+    assert_eq!(c.create.len(), 3);+    assert_eq!(c.expect.len(), 3);+    // one on the back with nets+    let one = be::add_footprint(&e, Some((R0603, "Resistor_SMD:R_0603_1608Metric")), &json!({"x": 180, "y": 80, "rotation": 90, "side": "B.Cu", "reference": "R900", "nets": {"1": "GND", "2": "+3V3"}})).unwrap();+    let b = parse(&one.candidate);+    let r = b.footprints.iter().find(|f| f.reference == "R900").unwrap();+    assert_eq!(r.layer.as_deref(), Some("B.Cu"));+    assert_eq!(r.position.as_ref().unwrap().rotation, Some(90.0));+    assert_eq!(r.pads.iter().map(|p| p.net_name.clone().unwrap_or_default()).collect::<Vec<_>>(), vec!["GND", "+3V3"]);+    assert!(r.pads.iter().all(|p| p.layers.as_ref().unwrap()[0] == "B.Cu"));+    assert_eq!(be::add_footprint(&e, Some((R0603, "x:y")), &json!({"x": 1, "y": 1, "reference": "C7"})).unwrap_err().code, "duplicate_reference");+    // duplicate of C7: new reference, no nets, no schematic link+    let d = be::add_footprint(&e, None, &json!({"duplicateOf": "C7", "x": 190, "y": 70})).unwrap();+    let b = parse(&d.candidate);+    let newref = d.report["added"][0]["ref"].as_str().unwrap();+    assert!(newref.starts_with('C') && newref != "C7");+    let dup = b.footprints.iter().find(|f| f.reference == newref).unwrap();+    assert_eq!(dup.footprint, "Capacitor_SMD:C_0805_2012Metric");+    assert!(dup.pads.iter().all(|p| p.net_name.is_none()));+    assert!(dup.path.is_none());+    let orig = b.footprints.iter().find(|f| f.reference == "C7").unwrap();+    assert_ne!(dup.uuid, orig.uuid);+    if let Some(rep) = kicad_loads("add", &one.candidate) {+        assert!(rep.get("kicad_version").is_some());+    }+}++/// The board positions of a footprint's pads, the way KiCad places them.+fn pad_xy(b: &pcb::Board, r: &str) -> Vec<(String, f64, f64, String)> {+    let mut v: Vec<(String, f64, f64, String)> = pcb::pads(b).into_iter().filter(|p| p.reference == r).map(|p| (p.pad, (p.x * 1e4).round() / 1e4, (p.y * 1e4).round() / 1e4, p.layers.first().cloned().unwrap_or_default())).collect();+    v.sort_by(|a, b| a.0.cmp(&b.0));+    v+}++#[test]+fn flip_mirrors_every_pad_about_the_anchor_and_twice_is_the_identity() {+    let e = esc();+    let before = parse(&e);+    let c7 = before.footprints.iter().find(|f| f.reference == "C7").unwrap();+    let (ax, _ay) = (c7.position.as_ref().unwrap().x, c7.position.as_ref().unwrap().y);+    let pads_before = pad_xy(&before, "C7");+    let c = be::flip_footprints(&e, &json!({"refs": ["C7"]})).unwrap();+    let after = parse(&c.candidate);+    let f = after.footprints.iter().find(|f| f.reference == "C7").unwrap();+    assert_eq!(f.layer.as_deref(), Some("B.Cu"));+    let pads_after = pad_xy(&after, "C7");+    for (b, a) in pads_before.iter().zip(&pads_after) {+        // a left/right flip about the anchor: x mirrors, y stays+        assert!((a.1 - (2.0 * ax - b.1)).abs() < 1e-3 && (a.2 - b.2).abs() < 1e-3, "pad {} {:?} -> {:?}", b.0, b, a);+    }+    assert_eq!(c.remove.len(), 1);+    assert_eq!(c.expect[0].side.as_deref(), Some("B.Cu"));+    assert!(!c.create[0].contains(c7.uuid.as_deref().unwrap()), "the live copy carries fresh UUIDs");+    assert!(c.candidate.contains(c7.uuid.as_deref().unwrap()), "the file keeps the UUID");+    let twice = be::flip_footprints(&c.candidate, &json!({"ref": "C7"})).unwrap();+    assert_eq!(pad_xy(&parse(&twice.candidate), "C7"), pads_before);+    let tb = be::flip_footprints(&e, &json!({"ref": "C7", "direction": "top_bottom"})).unwrap();+    let ay = c7.position.as_ref().unwrap().y;+    for (b, a) in pads_before.iter().zip(&pad_xy(&parse(&tb.candidate), "C7")) {+        assert!((a.1 - b.1).abs() < 1e-3 && (a.2 - (2.0 * ay - b.2)).abs() < 1e-3, "top/bottom pad {} {:?} -> {:?}", b.0, b, a);+    }+    assert_eq!(be::flip_footprints(&e, &json!({"ref": "NOPE"})).unwrap_err().code, "unknown_footprint");+    if let Some(rep) = kicad_loads("flip", &c.candidate) {+        assert!(rep.get("kicad_version").is_some());+    }+}++#[test]+fn delete_and_edit_footprints() {+    let e = esc();+    let d = be::delete_footprints(&e, &json!({"refs": ["C7", "U1"]})).unwrap();+    let b = parse(&d.candidate);+    assert!(!b.footprints.iter().any(|f| f.reference == "C7" || f.reference == "U1"));+    assert_eq!(b.footprints.len(), 147);+    assert_eq!(d.remove.len(), 2);+    assert!(d.expect.iter().all(|x| !x.present));+    let locked = be::flip_footprints(&e, &json!({"ref": "C7"})).unwrap().candidate;+    let locked = be::edit_footprints(&locked, &json!({"ref": "C7", "locked": true})).unwrap().candidate;+    assert_eq!(be::delete_footprints(&locked, &json!({"ref": "C7"})).unwrap_err().code, "footprint_locked");+    assert!(be::delete_footprints(&locked, &json!({"ref": "C7", "allowLocked": true})).is_ok());++    let ed = be::edit_footprints(&e, &json!({"ref": "C7", "value": "2u2", "fields": {"LCSC": "C99", "Note": "hand fit"}, "dnp": true, "excludeFromBom": true})).unwrap();+    let b = parse(&ed.candidate);+    let c7 = b.footprints.iter().find(|f| f.reference == "C7").unwrap();+    assert_eq!(c7.value, "2u2");+    assert_eq!(c7.properties.get("LCSC").map(String::as_str), Some("C99"));+    assert_eq!(c7.properties.get("Note").map(String::as_str), Some("hand fit"));+    assert!(c7.attr.contains("smd"));+    let row = &ed.report["edited"][0];+    assert_eq!(row["after"]["attributes"]["dnp"], true);+    assert_eq!(row["before"]["attributes"]["dnp"], false);+    let ren = be::edit_footprints(&e, &json!({"ref": "C7", "newReference": "C700"})).unwrap();+    assert!(parse(&ren.candidate).footprints.iter().any(|f| f.reference == "C700"));+    assert_eq!(be::edit_footprints(&e, &json!({"ref": "C7", "newReference": "C8"})).unwrap_err().code, "duplicate_reference");+    assert_eq!(be::edit_footprints(&e, &json!({"ref": "C7"})).unwrap_err().code, "missing_arg");+    assert_eq!(be::edit_footprints(&e, &json!({"ref": "C7", "fields": {"Value": null}})).unwrap_err().code, "invalid_args");+}++#[test]+fn align_and_distribute_by_courtyard() {+    let e = esc();+    let b = parse(&e);+    let (moves, rep) = be::align_moves(&b, &json!({"refs": ["C7", "C8", "C9"], "mode": "left"})).unwrap();+    assert_eq!(moves.len(), 3);+    let places = kicad_core::placement::placements(&b);+    let min_left = ["C7", "C8", "C9"].iter().map(|r| places.iter().find(|p| p.reference == *r).unwrap().courtyard.min_x).fold(f64::MAX, f64::min);+    for m in &moves {+        let p = places.iter().find(|p| p.reference == m["ref"].as_str().unwrap()).unwrap();+        let shift = m["x"].as_f64().unwrap() - p.x;+        assert!((p.courtyard.min_x + shift - min_left).abs() < 1e-6);+        assert_eq!(m["y"].as_f64().unwrap(), p.y);+    }+    assert_eq!(rep["mode"], "left");+    let (d, _) = be::align_moves(&b, &json!({"refs": ["C7", "C8", "C9"], "mode": "distributeY", "gap": 1})).unwrap();+    assert_eq!(d.len(), 3);+    assert!(be::align_moves(&b, &json!({"refs": ["C7"], "mode": "left"})).is_err());+    assert!(be::align_moves(&b, &json!({"refs": ["C7", "C8"], "mode": "diagonal"})).is_err());+    // the moves are exactly what kicad_move_footprint plans (file dry run)+    let plan = kicad_core::placement::plan_moves(&b, &json!({"refs": moves, "allowOverlap": true, "allowOutside": true})).unwrap();+    assert_eq!(plan.moves.len(), 3);+}++/// C7's own block turned back into a library footprint: the update finds nothing to do.+fn c7_as_library(e: &str) -> String {+    let root = sxedit::parse(e).unwrap();+    let c7 = be::footprints(&root).into_iter().find(|f| be::fp_ref(f) == "C7").unwrap();+    let block = kicad_core::fp_text::move_block(c7.src(e), 0.0, 0.0, 0.0).unwrap();+    let mut b = kicad_core::fp_text::strip_nets(&block).unwrap();+    b = kicad_core::fp_text::strip_symbol_link(&b).unwrap();+    b.replacen("Capacitor_SMD:C_0805_2012Metric", "C_0805_2012Metric", 1)+}++#[test]+fn update_from_library_plans_then_applies_keeping_nets_fields_and_pose() {+    let e = esc();+    let lib = c7_as_library(&e);+    let mut same = |_: &str| -> Result<String, String> { Ok(lib.clone()) };+    let plan = be::update_from_library(&e, &json!({"ref": "C7"}), &mut same).unwrap();+    assert_eq!(plan.report["footprints"][0]["status"], "up_to_date", "{}", plan.report);+    assert!(plan.remove.is_empty());+    // the library grew its pads: the plan says so, the apply keeps nets, fields and pose+    let bigger = lib.replace("(size 1 1.45)", "(size 1.2 1.6)");+    let mut changed = |id: &str| -> Result<String, String> { if id.starts_with("Capacitor_SMD:") { Ok(bigger.clone()) } else { Err(format!("{id} not in this test library")) } };+    let c = be::update_from_library(&e, &json!({"refs": ["C7", "U1"]}), &mut changed).unwrap();+    let rows = c.report["footprints"].as_array().unwrap();+    let c7 = rows.iter().find(|r| r["ref"] == "C7").unwrap();+    assert_eq!(c7["status"], "differs");+    assert_eq!(c7["diff"]["padsChanged"].as_array().unwrap().len(), 2);+    assert_eq!(rows.iter().find(|r| r["ref"] == "U1").unwrap()["status"], "library_not_found");+    let before = parse(&e);+    let after = parse(&c.candidate);+    let (b7, a7) = (before.footprints.iter().find(|f| f.reference == "C7").unwrap(), after.footprints.iter().find(|f| f.reference == "C7").unwrap());+    assert_eq!(a7.pads.iter().map(|p| p.net_name.clone()).collect::<Vec<_>>(), b7.pads.iter().map(|p| p.net_name.clone()).collect::<Vec<_>>());+    assert_eq!(a7.pads[0].width, Some(1.2));+    assert_eq!((a7.position.as_ref().unwrap().x, a7.position.as_ref().unwrap().rotation), (b7.position.as_ref().unwrap().x, b7.position.as_ref().unwrap().rotation));+    assert_eq!(a7.uuid, b7.uuid);+    assert_eq!(a7.path, b7.path);+    assert_eq!(a7.properties.get("LCSC"), b7.properties.get("LCSC"));+    assert_eq!(c.remove, vec![b7.uuid.clone().unwrap()]);+    if let Some(rep) = kicad_loads("update", &c.candidate) {+        assert!(rep.get("kicad_version").is_some());+    }+}++#[test]+fn footprint_graphics_and_models_list_and_edit() {+    let e = esc();+    let (list, none) = be::footprint_graphics(&e, &json!({"ref": "C7"})).unwrap();+    assert!(none.is_none());+    let g = list["graphics"].as_array().unwrap();+    let silk = g.iter().find(|g| g["layer"] == "F.SilkS" && g["kind"] == "line").unwrap();+    let uuid = silk["uuid"].as_str().unwrap();+    let (_, c) = be::footprint_graphics(&e, &json!({"ref": "C7", "uuid": uuid, "change": {"width": 0.2}})).unwrap();+    let c = c.unwrap();+    assert_eq!(c.report["after"]["width"], 0.2);+    assert_eq!(c.report["before"]["width"], 0.12);+    let (models, _) = be::footprint_models(&e, &json!({"ref": "C7"})).unwrap();+    assert_eq!(models["models"].as_array().unwrap().len(), 1);+    let (_, m) = be::footprint_models(&e, &json!({"ref": "C7", "ops": [{"action": "replace", "index": 0, "offset": [0, 0, 0.1], "rotate": [0, 0, 180]}]})).unwrap();+    let m = m.unwrap();+    assert_eq!(m.report["after"][0]["offset"], json!([0.0, 0.0, 0.1]));+    assert!(m.candidate.contains("(xyz 0 0 0.1)"));+    let (_, r) = be::footprint_models(&e, &json!({"ref": "C7", "action": "remove", "index": 0})).unwrap();+    assert_eq!(r.unwrap().report["after"].as_array().unwrap().len(), 0);+}++#[test]+fn cross_probe_from_reference_and_symbol_uuid_on_the_rp2040_project() {+    let pcb_text = std::fs::read_to_string(repo_file("tour-pack-rp2040/template/rp2040-breakout.kicad_pcb")).unwrap();+    let sch = std::fs::read_to_string(repo_file("tour-pack-rp2040/template/rp2040-breakout.kicad_sch")).unwrap();+    // The tour board was written without schematic links; give one footprint the (path ...)+    // KiCad's Update PCB from Schematic writes: "/<symbol uuid>" on a root sheet.+    let symbols = be::schematic_symbols(&sch);+    let b = parse(&pcb_text);+    let (sym_uuid, reference) = symbols.iter().find_map(|s| b.footprints.iter().any(|f| f.reference == s.1).then(|| (s.0.clone(), s.1.clone()))).expect("a symbol whose reference is on the board");+    let root = sxedit::parse(&pcb_text).unwrap();+    let fp = be::footprints(&root).into_iter().find(|f| be::fp_ref(f) == reference).unwrap();+    let at = fp.kid("at").unwrap();+    let linked_text = format!("{}\n\t\t(path \"/{sym_uuid}\")\n\t\t(sheetname \"/\")\n\t\t(sheetfile \"rp2040-breakout.kicad_sch\"){}", &pcb_text[..at.end], &pcb_text[at.end..]);+    let sheets = vec![("rp2040-breakout.kicad_sch".to_string(), sch)];+    let v = be::cross_probe(&linked_text, &sheets, &json!({"reference": reference})).unwrap();+    assert_eq!(v["count"], 1);+    let m = &v["matches"][0];+    assert_eq!(m["linked"], true);+    assert_eq!(m["symbolUuid"], sym_uuid.as_str());+    assert_eq!(m["symbol"]["uuid"], sym_uuid.as_str(), "the schematic symbol is found by the board's path link: {v}");+    assert_eq!(m["symbol"]["reference"], reference.as_str());+    let back = be::cross_probe(&linked_text, &sheets, &json!({"symbolUuid": sym_uuid})).unwrap();+    assert_eq!(back["matches"][0]["ref"], reference.as_str());+    let by_path = be::cross_probe(&linked_text, &sheets, &json!({"path": format!("/{sym_uuid}")})).unwrap();+    assert_eq!(by_path["count"], 1);+    // a symbol with no footprint is named as such+    let orphan = symbols.iter().find(|s| !b.footprints.iter().any(|f| f.reference == s.1) && !s.1.starts_with('#'));+    if let Some(o) = orphan {+        let r = be::cross_probe(&linked_text, &sheets, &json!({"symbolUuid": o.0})).unwrap();+        assert_eq!(r["count"], 0);+        assert_eq!(r["symbolWithoutFootprint"]["uuid"], o.0.as_str());+    }+    assert_eq!(be::cross_probe(&linked_text, &sheets, &json!({})).unwrap_err().code, "missing_arg");+}++#[test]+fn title_block_and_extents_of_the_esc() {+    let e = esc();+    let tb = be::title_block(&e);+    assert_eq!(tb["title"], "ESC: G431");+    assert_eq!(tb["rev"], "2.0.1");+    assert_eq!(tb["company"], "ADOM INDUSTRIES INC.");+    let x = be::extents(&parse(&e));+    assert!(x["x0"].as_f64().unwrap() <= 104.0 && x["x1"].as_f64().unwrap() >= 168.0);+}++#[test]+fn closed_file_write_backs_up_refuses_open_boards_and_stale_text() {+    let dir = temp_dir("write");+    let path = dir.join("b.kicad_pcb");+    let t = blank();+    std::fs::write(&path, &t).unwrap();+    let c: Change = be::outline(&t, &json!({"width": 20, "height": 20, "x": 0, "y": 0})).unwrap();+    let bak = be::write_closed(&path, &t, &c.candidate).unwrap();+    assert_eq!(std::fs::read_to_string(&bak).unwrap(), t);+    assert_eq!(std::fs::read_to_string(&path).unwrap(), c.candidate);+    // stale: the file no longer holds the text the edit was planned on+    assert_eq!(be::write_closed(&path, &t, &c.candidate).unwrap_err().code, "stale_board");+    std::fs::write(dir.join("~b.kicad_pcb.lck"), "x").unwrap();+    assert_eq!(be::write_closed(&path, &c.candidate, &t).unwrap_err().code, "board_open");+    let _ = std::fs::remove_dir_all(&dir);+}++#[test]+fn the_live_create_text_wraps_blocks_in_the_boards_header() {+    let e = esc();+    let w = be::create_text(&e, &["(gr_line (start 0 0) (end 1 1) (layer \"Dwgs.User\"))".to_string()]);+    let root = sxedit::parse(&w).unwrap();+    assert_eq!(root.tag(), Some("kicad_pcb"));+    assert_eq!(root.value_of("version"), Some("20260206"));+    assert!(root.kid("layers").is_some());+    assert!(root.kid("gr_line").is_some());+    let b = parse(&w);+    assert_eq!(pcb::copper_layers(&b).len(), 4);+    // what a live flip of C7 hands ParseAndCreateItemsFromString is a board KiCad reads+    let flip = be::flip_footprints(&e, &json!({"ref": "C7"})).unwrap();+    let w = be::create_text(&e, &flip.create);+    let b = parse(&w);+    assert_eq!(b.footprints.len(), 1);+    assert_eq!(b.footprints[0].layer.as_deref(), Some("B.Cu"));+    assert_eq!(b.footprints[0].pads.iter().filter(|p| p.net_name.is_some()).count(), 2, "the pads keep their nets by name");+    if let Some(rep) = kicad_loads("create-text", &w) {+        assert!(rep.get("kicad_version").is_some());+    }+}++/// Pad flashes (D03) in a Gerber file, in KiCad's frame (Gerber y is up), rounded to 1 um.+fn flashes(gbr: &str) -> Vec<(i64, i64)> {+    let mut out = Vec::new();+    let (mut x, mut y) = (0i64, 0i64);+    for line in gbr.lines() {+        let l = line.trim();+        if !l.ends_with("D03*") || !l.starts_with('X') && !l.starts_with('Y') {+            continue;+        }+        let num = |tag: char| -> Option<i64> {+            let i = l.find(tag)? + 1;+            let s: String = l[i..].chars().take_while(|c| c.is_ascii_digit() || *c == '-').collect();+            s.parse().ok()+        };+        if let Some(v) = num('X') { x = v; }+        if let Some(v) = num('Y') { y = v; }+        out.push(((x as f64 / 1000.0).round() as i64, (-(y as f64) / 1000.0).round() as i64));+    }+    out.sort();+    out+}++fn gerber(tag: &str, text: &str, layer: &str) -> Option<String> {+    let svc = service_kicad()?;+    let dir = temp_dir(tag);+    let path = dir.join(format!("{tag}.kicad_pcb"));+    std::fs::write(&path, text).unwrap();+    let out_dir = dir.join("gerbers");+    let out = Command::new(&svc).args(["pcb", "export", "gerbers"]).arg(&path).arg("--out").arg(&out_dir).output().ok()?;+    let want = layer.replace('.', "_");+    let file = std::fs::read_dir(&out_dir).ok()?.flatten().map(|e| e.path()).find(|p| p.file_name().map(|n| n.to_string_lossy().contains(&want)).unwrap_or(false));+    match file {+        Some(f) => Some(std::fs::read_to_string(f).unwrap()),+        None => {+            eprintln!("no {layer} gerber from service-kicad for {tag}: {}{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout));+            None+        }+    }+}++/// KiCad itself draws the flipped footprint's pads where the bridge mirrored them: the+/// B.Cu Gerber of the flipped part is the F.Cu Gerber mirrored about the anchor.+#[test]+fn kicad_draws_the_flipped_pads_at_the_mirrored_positions() {+    let lib = "(footprint \"Asym\" (layer \"F.Cu\") (property \"Reference\" \"REF**\" (at 0 -2 0) (layer \"F.SilkS\") (effects (font (size 1 1) (thickness 0.15)))) (property \"Value\" \"Asym\" (at 0 2 0) (layer \"F.Fab\") (effects (font (size 1 1) (thickness 0.15)))) (attr smd) (pad \"1\" smd circle (at -1 0.5) (size 0.6 0.6) (layers \"F.Cu\" \"F.Mask\")) (pad \"2\" smd circle (at 2 -1) (size 0.4 0.4) (layers \"F.Cu\" \"F.Mask\")) (pad \"3\" smd circle (at 0.3 1.7) (size 0.5 0.5) (layers \"F.Cu\" \"F.Mask\")))";+    let base = blank();+    let c = be::add_footprint(&base, Some((lib, "Test:Asym")), &json!({"x": 100, "y": 100, "rotation": 30, "reference": "U1"})).unwrap();+    let Some(front) = gerber("flip-front", &c.candidate, "F.Cu") else { eprintln!("service-kicad not available; skipping"); return };+    let f = flashes(&front);+    assert_eq!(f.len(), 3, "three pad flashes on F.Cu: {front}");+    // the bridge's own pad positions agree with KiCad's drawing+    let mut ours: Vec<(i64, i64)> = pcb::pads(&parse(&c.candidate)).iter().map(|p| ((p.x * 1000.0).round() as i64, (p.y * 1000.0).round() as i64)).collect();+    ours.sort();+    assert_eq!(ours, f);+    for (dir, tag) in [("left_right", "flip-lr"), ("top_bottom", "flip-tb")] {+        let flipped = be::flip_footprints(&c.candidate, &json!({"ref": "U1", "direction": dir})).unwrap();+        let back = gerber(tag, &flipped.candidate, "B.Cu").expect("B.Cu gerber");+        let b = flashes(&back);+        let mut want: Vec<(i64, i64)> = f.iter().map(|(x, y)| if dir == "left_right" { (200_000 - x, *y) } else { (*x, 200_000 - y) }).collect();+        want.sort();+        assert_eq!(b, want, "{dir}: KiCad drew the flipped pads at {b:?}, the mirror is {want:?}");+    }+}
rust/crates/kicad-core/tests/fab_mini.rsadded+47
@@ -0,0 +1,47 @@+//! The fab helpers end to end on a small KiCad 10 board fixture (two resistors, a header,+//! a closed outline, a little copper): the pre-flight, the quote numbers, the JLCPCB BOM and+//! a stored zip that the bridge's own reader opens.++use std::path::Path;++use kicad_core::{dfm, fab};++fn fixture() -> String {+    std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/fab-mini.kicad_pcb")).unwrap()+}++#[test]+fn fixture_parses_with_the_board_reader() {+    let b = kicad_core::pcb::parse_pcb_text(&fixture(), "fab-mini").unwrap();+    assert_eq!(b.footprints.len(), 3);+}++#[test]+fn preflight_numbers_bom_and_zip() {+    let text = fixture();+    let g = dfm::Geometry::from_text(&text).unwrap();+    assert!(g.outline_closed().0);+    let r = dfm::validate(&g, &dfm::builtin_profile("jlcpcb").unwrap(), &dfm::Options { assembly: true, max_items: 5 }, Err("no kicad-cli in the test"));+    let checks: Vec<&str> = r["findings"].as_array().unwrap().iter().map(|f| f["check"].as_str().unwrap()).collect();+    assert_eq!(r["pass"], true, "{r}");+    assert!(checks.contains(&"3d_models"), "J1 has no model: {checks:?}");+    assert!(checks.contains(&"fiducials"));+    assert!(!checks.contains(&"min_space") && !checks.contains(&"acid_traps") && !checks.contains(&"edge_clearance"), "{checks:?}");++    let n = fab::board_numbers(&text).unwrap();+    assert_eq!(n["widthMm"], 30.0);+    assert_eq!(n["layers"], 2);+    assert_eq!(n["smdParts"], 2);+    assert_eq!(n["thtParts"], 1);+    assert_eq!(n["uniqueParts"], 2);+    assert_eq!(n["holes"], 4, "three header pins and one via");+    assert_eq!(n["minTrackMm"], 0.25);++    let lines = fab::group_bom(&fab::parts_from_board(&text).unwrap());+    let csv = fab::bom_csv(&lines, "jlcpcb");+    assert_eq!(csv, "Comment,Designator,Footprint,LCSC Part #\nConn_01x03,J1,PinHeader_1x03_P2.54mm_Vertical,\n10k,\"R1,R2\",R_0603_1608Metric,C25804\n");++    let zip = fab::zip_stored(&[("bom/fab-mini_BOM_JLCPCB.csv".into(), csv.clone().into_bytes()), ("README.txt".into(), b"x".to_vec())]).unwrap();+    let z = kicad_core::pcm::ZipFile::parse(&zip).unwrap();+    assert_eq!(z.entries.len(), 2);+}
rust/crates/kicad-core/tests/fixtures/fab-mini.kicad_pcbadded+60
@@ -0,0 +1,60 @@+(kicad_pcb+	(version 20260206)+	(generator "pcbnew")+	(generator_version "10.0")+	(general+		(thickness 1.6)+	)+	(layers+		(0 "F.Cu" signal)+		(2 "B.Cu" signal)+		(5 "F.SilkS" user "F.Silkscreen")+		(25 "Edge.Cuts" user)+	)+	(footprint "Resistor_SMD:R_0603_1608Metric"+		(layer "F.Cu")+		(at 10 10)+		(property "Reference" "R1")+		(property "Value" "10k")+		(property "MPN" "RC0603FR-0710KL")+		(property "LCSC" "C25804")+		(attr smd)+		(pad "1" smd roundrect (at -0.8 0) (size 0.8 0.95) (layers "F.Cu" "F.Mask" "F.Paste") (roundrect_rratio 0.25) (net "VIN"))+		(pad "2" smd roundrect (at 0.8 0) (size 0.8 0.95) (layers "F.Cu" "F.Mask" "F.Paste") (roundrect_rratio 0.25) (net "OUT"))+		(model "${KICAD10_3DMODEL_DIR}/Resistor_SMD.3dshapes/R_0603_1608Metric.step")+	)+	(footprint "Resistor_SMD:R_0603_1608Metric"+		(layer "F.Cu")+		(at 10 14)+		(property "Reference" "R2")+		(property "Value" "10k")+		(property "MPN" "RC0603FR-0710KL")+		(property "LCSC" "C25804")+		(attr smd)+		(pad "1" smd roundrect (at -0.8 0) (size 0.8 0.95) (layers "F.Cu" "F.Mask" "F.Paste") (roundrect_rratio 0.25) (net "OUT"))+		(pad "2" smd roundrect (at 0.8 0) (size 0.8 0.95) (layers "F.Cu" "F.Mask" "F.Paste") (roundrect_rratio 0.25) (net "GND"))+		(model "${KICAD10_3DMODEL_DIR}/Resistor_SMD.3dshapes/R_0603_1608Metric.step")+	)+	(footprint "Connector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_Vertical"+		(layer "F.Cu")+		(at 22 9.46)+		(property "Reference" "J1")+		(property "Value" "Conn_01x03")+		(attr through_hole)+		(pad "1" thru_hole rect (at 0 0) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (net "VIN"))+		(pad "2" thru_hole oval (at 0 2.54) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (net "OUT"))+		(pad "3" thru_hole oval (at 0 5.08) (size 1.7 1.7) (drill 1) (layers "*.Cu" "*.Mask") (net "GND"))+	)+	(gr_rect (start 0 0) (end 30 20) (stroke (width 0.05) (type default)) (fill no) (layer "Edge.Cuts"))+	(segment (start 9.2 10) (end 9.2 7) (width 0.25) (layer "F.Cu") (net "VIN"))+	(segment (start 9.2 7) (end 22 7) (width 0.25) (layer "F.Cu") (net "VIN"))+	(segment (start 22 7) (end 22 9.46) (width 0.25) (layer "F.Cu") (net "VIN"))+	(segment (start 10.8 10) (end 10.8 12) (width 0.25) (layer "F.Cu") (net "OUT"))+	(segment (start 10.8 12) (end 9.2 12) (width 0.25) (layer "F.Cu") (net "OUT"))+	(segment (start 9.2 12) (end 9.2 14) (width 0.25) (layer "F.Cu") (net "OUT"))+	(segment (start 10.8 12) (end 15 12) (width 0.25) (layer "F.Cu") (net "OUT"))+	(via (at 15 12) (size 0.6) (drill 0.3) (layers "F.Cu" "B.Cu") (net "OUT"))+	(segment (start 15 12) (end 22 12) (width 0.25) (layer "B.Cu") (net "OUT"))+	(segment (start 10.8 14) (end 22 14.54) (width 0.25) (layer "F.Cu") (net "GND"))+	(embedded_fonts no)+)
rust/crates/kicad-core/tests/fixtures/konnect-route/Fixture.pretty/PinHeader_1x04_P2.00mm_Vertical.kicad_modadded+69
@@ -0,0 +1,69 @@+(footprint "PinHeader_1x04_P2.00mm_Vertical"+	(version 20260206)+	(generator "pcbnew")+	(generator_version "10.0")+	(layer "F.Cu")+	(property "Reference" "REF**"+		(at 0 -2 0)+		(layer "F.SilkS")+		(uuid "00000000-0000-4000-8000-000000000087")+		(effects+			(font+				(size 1 1)+				(thickness 0.15)+			)+		)+	)+	(property "Value" "PinHeader_1x04_P2.00mm_Vertical"+		(at 0 2 0)+		(layer "F.Fab")+		(uuid "00000000-0000-4000-8000-000000000088")+		(effects+			(font+				(size 1 1)+				(thickness 0.15)+			)+		)+	)+	(attr through_hole)+	(fp_rect+		(start -1.1 -1.1)+		(end 1.1 7.1)+		(stroke+			(width 0.05)+			(type solid)+		)+		(fill no)+		(layer "F.CrtYd")+		(uuid "00000000-0000-4000-8000-000000000089")+	)+	(pad "1" thru_hole rect+		(at 0 0)+		(size 1.35 1.35)+		(drill 0.8)+		(layers "*.Cu" "*.Mask")+		(uuid "00000000-0000-4000-8000-000000000090")+	)+	(pad "2" thru_hole circle+		(at 0 2.0)+		(size 1.35 1.35)+		(drill 0.8)+		(layers "*.Cu" "*.Mask")+		(uuid "00000000-0000-4000-8000-000000000091")+	)+	(pad "3" thru_hole circle+		(at 0 4.0)+		(size 1.35 1.35)+		(drill 0.8)+		(layers "*.Cu" "*.Mask")+		(uuid "00000000-0000-4000-8000-000000000092")+	)+	(pad "4" thru_hole circle+		(at 0 6.0)+		(size 1.35 1.35)+		(drill 0.8)+		(layers "*.Cu" "*.Mask")+		(uuid "00000000-0000-4000-8000-000000000093")+	)+	(embedded_fonts no)+)
rust/crates/kicad-core/tests/fixtures/konnect-route/Fixture.pretty/R_0603_1608Metric.kicad_modadded+53
@@ -0,0 +1,53 @@+(footprint "R_0603_1608Metric"+	(version 20260206)+	(generator "pcbnew")+	(generator_version "10.0")+	(layer "F.Cu")+	(property "Reference" "REF**"+		(at 0 -2 0)+		(layer "F.SilkS")+		(uuid "00000000-0000-4000-8000-000000000082")+		(effects+			(font+				(size 1 1)+				(thickness 0.15)+			)+		)+	)+	(property "Value" "R_0603_1608Metric"+		(at 0 2 0)+		(layer "F.Fab")+		(uuid "00000000-0000-4000-8000-000000000083")+		(effects+			(font+				(size 1 1)+				(thickness 0.15)+			)+		)+	)+	(attr smd)+	(fp_rect+		(start -1.48 -0.73)+		(end 1.48 0.73)+		(stroke+			(width 0.05)+			(type solid)+		)+		(fill no)+		(layer "F.CrtYd")+		(uuid "00000000-0000-4000-8000-000000000084")+	)+	(pad "1" smd roundrect+		(at -0.825 0)+		(size 0.8 0.95)+		(layers "F.Cu" "F.Mask" "F.Paste")+		(uuid "00000000-0000-4000-8000-000000000085")+	)+	(pad "2" smd roundrect+		(at 0.825 0)+		(size 0.8 0.95)+		(layers "F.Cu" "F.Mask" "F.Paste")+		(uuid "00000000-0000-4000-8000-000000000086")+	)+	(embedded_fonts no)+)
rust/crates/kicad-core/tests/fixtures/konnect-route/fp-lib-tableadded+4
@@ -0,0 +1,4 @@+(fp_lib_table+	(version 7)+	(lib (name "Fixture")(type "KiCad")(uri "${KIPRJMOD}/Fixture.pretty")(options "")(descr "konnect-route fixture library"))+)
rust/crates/kicad-core/tests/fixtures/konnect-route/route-fixture.kicad_druadded+7
@@ -0,0 +1,7 @@+(version 1)+(rule "HV clearance"+	(condition "A.NetClass == 'HV' || B.NetClass == 'HV'")+	(constraint clearance (min 0.5mm)))+(rule "Inner thin"+	(layer inner)+	(constraint track_width (min 0.1mm) (opt 0.15mm)))
rust/crates/kicad-core/tests/fixtures/konnect-route/route-fixture.kicad_pcbadded+770
@@ -0,0 +1,770 @@+(kicad_pcb+	(version 20260206)+	(generator "pcbnew")+	(generator_version "10.0")+	(general+		(thickness 1.6)+		(legacy_teardrops no)+	)+	(paper "A4")+	(layers+		(0 "F.Cu" signal)+		(2 "B.Cu" signal)+		(9 "F.Adhes" user "F.Adhesive")+		(11 "B.Adhes" user "B.Adhesive")+		(13 "F.Paste" user)+		(15 "B.Paste" user)+		(5 "F.SilkS" user "F.Silkscreen")+		(7 "B.SilkS" user "B.Silkscreen")+		(1 "F.Mask" user)+		(3 "B.Mask" user)+		(17 "Dwgs.User" user "User.Drawings")+		(19 "Cmts.User" user "User.Comments")+		(21 "Eco1.User" user "User.Eco1")+		(23 "Eco2.User" user "User.Eco2")+		(25 "Edge.Cuts" user)+		(27 "Margin" user)+		(31 "F.CrtYd" user "F.Courtyard")+		(29 "B.CrtYd" user "B.Courtyard")+		(35 "F.Fab" user)+		(33 "B.Fab" user)+		(39 "User.1" user)+		(41 "User.2" user)+		(43 "User.3" user)+		(45 "User.4" user)+	)+	(setup+		(pad_to_mask_clearance 0)+		(allow_soldermask_bridges_in_footprints no)+		(tenting+			(front yes)+			(back yes)+		)+		(covering+			(front no)+			(back no)+		)+		(plugging+			(front no)+			(back no)+		)+		(capping no)+		(filling no)+		(pcbplotparams+			(layerselection 0x00000000_00000000_55555555_5755f5ff)+			(plot_on_all_layers_selection 0x00000000_00000000_00000000_00000000)+			(disableapertmacros no)+			(usegerberextensions no)+			(usegerberattributes yes)+			(usegerberadvancedattributes yes)+			(creategerberjobfile yes)+			(dashed_line_dash_ratio 12)+			(dashed_line_gap_ratio 3)+			(svgprecision 4)+			(plotframeref no)+			(mode 1)+			(useauxorigin no)+			(pdf_front_fp_property_popups yes)+			(pdf_back_fp_property_popups yes)+			(pdf_metadata yes)+			(pdf_single_document no)+			(dxfpolygonmode yes)+			(dxfimperialunits yes)+			(dxfusepcbnewfont yes)+			(psnegative no)+			(psa4output no)+			(plot_black_and_white yes)+			(sketchpadsonfab no)+			(plotpadnumbers no)+			(hidednponfab no)+			(sketchdnponfab yes)+			(crossoutdnponfab yes)+			(subtractmaskfromsilk no)+			(outputformat 1)+			(mirror no)+			(drillshape 1)+			(scaleselection 1)+			(outputdirectory "")+		)+	)+	(footprint "Fixture:QFN-16_4x4mm_P0.65mm"+		(layer "F.Cu")+		(uuid "00000000-0000-4000-8000-000000000001")+		(at 20 20)+		(property "Reference" "U1"+			(at 0 -3 0)+			(layer "F.SilkS")+			(uuid "00000000-0000-4000-8000-000000000002")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(property "Value" "MCU"+			(at 0 3 0)+			(layer "F.Fab")+			(hide yes)+			(uuid "00000000-0000-4000-8000-000000000003")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(path "/10000000-0000-4000-8000-000000000001")+		(sheetname "/")+		(sheetfile "route-fixture.kicad_sch")+		(attr smd)+		(fp_rect+			(start -2.6 -2.6)+			(end 2.6 2.6)+			(stroke+				(width 0.05)+				(type solid)+			)+			(fill no)+			(layer "F.CrtYd")+			(uuid "00000000-0000-4000-8000-000000000004")+		)+		(pad "1" smd roundrect+			(at -1.95 -0.975)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "+3V3")+			(uuid "00000000-0000-4000-8000-000000000005")+		)+		(pad "2" smd roundrect+			(at -1.95 -0.32499999999999996)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "SIG_A")+			(uuid "00000000-0000-4000-8000-000000000006")+		)+		(pad "3" smd roundrect+			(at -1.95 0.32500000000000007)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "USB_D+")+			(uuid "00000000-0000-4000-8000-000000000007")+		)+		(pad "4" smd roundrect+			(at -1.95 0.9750000000000002)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "USB_D-")+			(uuid "00000000-0000-4000-8000-000000000008")+		)+		(pad "5" smd roundrect+			(at -0.975 1.95 90)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "+3V3")+			(uuid "00000000-0000-4000-8000-000000000009")+		)+		(pad "6" smd roundrect+			(at -0.32499999999999996 1.95 90)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(uuid "00000000-0000-4000-8000-000000000010")+		)+		(pad "7" smd roundrect+			(at 0.32500000000000007 1.95 90)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(uuid "00000000-0000-4000-8000-000000000011")+		)+		(pad "8" smd roundrect+			(at 0.9750000000000002 1.95 90)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(uuid "00000000-0000-4000-8000-000000000012")+		)+		(pad "9" smd roundrect+			(at 1.95 0.975)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "SIG_B")+			(uuid "00000000-0000-4000-8000-000000000013")+		)+		(pad "10" smd roundrect+			(at 1.95 0.32499999999999996)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(uuid "00000000-0000-4000-8000-000000000014")+		)+		(pad "11" smd roundrect+			(at 1.95 -0.32500000000000007)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(uuid "00000000-0000-4000-8000-000000000015")+		)+		(pad "12" smd roundrect+			(at 1.95 -0.9750000000000002)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(uuid "00000000-0000-4000-8000-000000000016")+		)+		(pad "13" smd roundrect+			(at 0.975 -1.95 90)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(uuid "00000000-0000-4000-8000-000000000017")+		)+		(pad "14" smd roundrect+			(at 0.32499999999999996 -1.95 90)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(uuid "00000000-0000-4000-8000-000000000018")+		)+		(pad "15" smd roundrect+			(at -0.32500000000000007 -1.95 90)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(uuid "00000000-0000-4000-8000-000000000019")+		)+		(pad "16" smd roundrect+			(at -0.9750000000000002 -1.95 90)+			(size 0.8 0.3)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(uuid "00000000-0000-4000-8000-000000000020")+		)+		(pad "17" smd rect+			(at 0 0)+			(size 2.6 2.6)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "GND")+			(uuid "00000000-0000-4000-8000-000000000021")+		)+		(embedded_fonts no)+	)+	(footprint "Fixture:BGA-16_4x4_P0.8mm"+		(layer "F.Cu")+		(uuid "00000000-0000-4000-8000-000000000022")+		(at 42 20)+		(property "Reference" "U2"+			(at 0 -3 0)+			(layer "F.SilkS")+			(uuid "00000000-0000-4000-8000-000000000023")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(property "Value" "FPGA"+			(at 0 3 0)+			(layer "F.Fab")+			(hide yes)+			(uuid "00000000-0000-4000-8000-000000000024")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(path "/10000000-0000-4000-8000-000000000002")+		(sheetname "/")+		(sheetfile "route-fixture.kicad_sch")+		(attr smd)+		(fp_rect+			(start -2.0 -2.0)+			(end 2.0 2.0)+			(stroke+				(width 0.05)+				(type solid)+			)+			(fill no)+			(layer "F.CrtYd")+			(uuid "00000000-0000-4000-8000-000000000025")+		)+		(pad "A1" smd circle+			(at -1.2 -1.2)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "GND")+			(uuid "00000000-0000-4000-8000-000000000026")+		)+		(pad "A2" smd circle+			(at -0.3999999999999999 -1.2)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "BGA_A2")+			(uuid "00000000-0000-4000-8000-000000000027")+		)+		(pad "A3" smd circle+			(at 0.40000000000000013 -1.2)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "BGA_A3")+			(uuid "00000000-0000-4000-8000-000000000028")+		)+		(pad "A4" smd circle+			(at 1.2000000000000004 -1.2)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "GND")+			(uuid "00000000-0000-4000-8000-000000000029")+		)+		(pad "B1" smd circle+			(at -1.2 -0.3999999999999999)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "BGA_B1")+			(uuid "00000000-0000-4000-8000-000000000030")+		)+		(pad "B2" smd circle+			(at -0.3999999999999999 -0.3999999999999999)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "BGA_B2")+			(uuid "00000000-0000-4000-8000-000000000031")+		)+		(pad "B3" smd circle+			(at 0.40000000000000013 -0.3999999999999999)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "BGA_B3")+			(uuid "00000000-0000-4000-8000-000000000032")+		)+		(pad "B4" smd circle+			(at 1.2000000000000004 -0.3999999999999999)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "BGA_B4")+			(uuid "00000000-0000-4000-8000-000000000033")+		)+		(pad "C1" smd circle+			(at -1.2 0.40000000000000013)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "BGA_C1")+			(uuid "00000000-0000-4000-8000-000000000034")+		)+		(pad "C2" smd circle+			(at -0.3999999999999999 0.40000000000000013)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "BGA_C2")+			(uuid "00000000-0000-4000-8000-000000000035")+		)+		(pad "C3" smd circle+			(at 0.40000000000000013 0.40000000000000013)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "BGA_C3")+			(uuid "00000000-0000-4000-8000-000000000036")+		)+		(pad "C4" smd circle+			(at 1.2000000000000004 0.40000000000000013)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "BGA_C4")+			(uuid "00000000-0000-4000-8000-000000000037")+		)+		(pad "D1" smd circle+			(at -1.2 1.2000000000000004)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "GND")+			(uuid "00000000-0000-4000-8000-000000000038")+		)+		(pad "D2" smd circle+			(at -0.3999999999999999 1.2000000000000004)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "BGA_D2")+			(uuid "00000000-0000-4000-8000-000000000039")+		)+		(pad "D3" smd circle+			(at 0.40000000000000013 1.2000000000000004)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "BGA_D3")+			(uuid "00000000-0000-4000-8000-000000000040")+		)+		(pad "D4" smd circle+			(at 1.2000000000000004 1.2000000000000004)+			(size 0.4 0.4)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "GND")+			(uuid "00000000-0000-4000-8000-000000000041")+		)+		(embedded_fonts no)+	)+	(footprint "Fixture:C_0402_1005Metric"+		(layer "F.Cu")+		(uuid "00000000-0000-4000-8000-000000000042")+		(at 8 35)+		(property "Reference" "C1"+			(at 0 -3 0)+			(layer "F.SilkS")+			(uuid "00000000-0000-4000-8000-000000000043")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(property "Value" "100n"+			(at 0 3 0)+			(layer "F.Fab")+			(hide yes)+			(uuid "00000000-0000-4000-8000-000000000044")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(path "/10000000-0000-4000-8000-000000000003")+		(sheetname "/")+		(sheetfile "route-fixture.kicad_sch")+		(attr smd)+		(fp_rect+			(start -0.93 -0.47)+			(end 0.93 0.47)+			(stroke+				(width 0.05)+				(type solid)+			)+			(fill no)+			(layer "F.CrtYd")+			(uuid "00000000-0000-4000-8000-000000000045")+		)+		(pad "1" smd roundrect+			(at -0.48 0)+			(size 0.56 0.62)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "+3V3")+			(uuid "00000000-0000-4000-8000-000000000046")+		)+		(pad "2" smd roundrect+			(at 0.48 0)+			(size 0.56 0.62)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "GND")+			(uuid "00000000-0000-4000-8000-000000000047")+		)+		(embedded_fonts no)+	)+	(footprint "Fixture:C_0402_1005Metric"+		(layer "F.Cu")+		(uuid "00000000-0000-4000-8000-000000000048")+		(at 12 35)+		(property "Reference" "C2"+			(at 0 -3 0)+			(layer "F.SilkS")+			(uuid "00000000-0000-4000-8000-000000000049")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(property "Value" "100n"+			(at 0 3 0)+			(layer "F.Fab")+			(hide yes)+			(uuid "00000000-0000-4000-8000-000000000050")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(path "/10000000-0000-4000-8000-000000000004")+		(sheetname "/")+		(sheetfile "route-fixture.kicad_sch")+		(attr smd)+		(fp_rect+			(start -0.93 -0.47)+			(end 0.93 0.47)+			(stroke+				(width 0.05)+				(type solid)+			)+			(fill no)+			(layer "F.CrtYd")+			(uuid "00000000-0000-4000-8000-000000000051")+		)+		(pad "1" smd roundrect+			(at -0.48 0)+			(size 0.56 0.62)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "+3V3")+			(uuid "00000000-0000-4000-8000-000000000052")+		)+		(pad "2" smd roundrect+			(at 0.48 0)+			(size 0.56 0.62)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "GND")+			(uuid "00000000-0000-4000-8000-000000000053")+		)+		(embedded_fonts no)+	)+	(footprint "Fixture:PinHeader_1x04_P2.54mm_Vertical"+		(layer "F.Cu")+		(uuid "00000000-0000-4000-8000-000000000054")+		(at 30 26)+		(property "Reference" "J1"+			(at 0 -3 0)+			(layer "F.SilkS")+			(uuid "00000000-0000-4000-8000-000000000055")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(property "Value" "USB"+			(at 0 3 0)+			(layer "F.Fab")+			(hide yes)+			(uuid "00000000-0000-4000-8000-000000000056")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(path "/10000000-0000-4000-8000-000000000005")+		(sheetname "/")+		(sheetfile "route-fixture.kicad_sch")+		(attr through_hole)+		(fp_rect+			(start -1.3 -8.9)+			(end 1.3 8.9)+			(stroke+				(width 0.05)+				(type solid)+			)+			(fill no)+			(layer "F.CrtYd")+			(uuid "00000000-0000-4000-8000-000000000057")+		)+		(pad "1" thru_hole rect+			(at 0 0)+			(size 1.7 1.7)+			(drill 1.0)+			(layers "*.Cu" "*.Mask")+			(net "+3V3")+			(uuid "00000000-0000-4000-8000-000000000058")+		)+		(pad "2" thru_hole circle+			(at 0 2.54)+			(size 1.7 1.7)+			(drill 1.0)+			(layers "*.Cu" "*.Mask")+			(net "USB_D+")+			(uuid "00000000-0000-4000-8000-000000000059")+		)+		(pad "3" thru_hole circle+			(at 0 5.08)+			(size 1.7 1.7)+			(drill 1.0)+			(layers "*.Cu" "*.Mask")+			(net "USB_D-")+			(uuid "00000000-0000-4000-8000-000000000060")+		)+		(pad "4" thru_hole circle+			(at 0 7.62)+			(size 1.7 1.7)+			(drill 1.0)+			(layers "*.Cu" "*.Mask")+			(net "GND")+			(uuid "00000000-0000-4000-8000-000000000061")+		)+		(embedded_fonts no)+	)+	(footprint "Fixture:R_0603_1608Metric"+		(layer "F.Cu")+		(uuid "00000000-0000-4000-8000-000000000063")+		(at 50 34)+		(property "Reference" "R1"+			(at 0 -3 0)+			(layer "F.SilkS")+			(uuid "00000000-0000-4000-8000-000000000064")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(property "Value" "10k"+			(at 0 3 0)+			(layer "F.Fab")+			(hide yes)+			(uuid "00000000-0000-4000-8000-000000000065")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(path "/10000000-0000-4000-8000-000000000006")+		(sheetname "/")+		(sheetfile "route-fixture.kicad_sch")+		(attr smd)+		(fp_rect+			(start -1.48 -0.73)+			(end 1.48 0.73)+			(stroke+				(width 0.05)+				(type solid)+			)+			(fill no)+			(layer "F.CrtYd")+			(uuid "00000000-0000-4000-8000-000000000066")+		)+		(pad "1" smd roundrect+			(at -0.825 0)+			(size 0.8 0.95)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "SIG_A")+			(uuid "00000000-0000-4000-8000-000000000067")+		)+		(pad "2" smd roundrect+			(at 0.825 0)+			(size 0.8 0.95)+			(layers "F.Cu" "F.Mask" "F.Paste")+			(net "SIG_B")+			(uuid "00000000-0000-4000-8000-000000000068")+		)+		(embedded_fonts no)+	)+	(footprint "Fixture:MountingHole_3.2mm"+		(layer "F.Cu")+		(uuid "00000000-0000-4000-8000-000000000069")+		(at 55 5)+		(property "Reference" "MH1"+			(at 0 -3 0)+			(layer "F.SilkS")+			(uuid "00000000-0000-4000-8000-000000000070")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(property "Value" "MountingHole"+			(at 0 3 0)+			(layer "F.Fab")+			(hide yes)+			(uuid "00000000-0000-4000-8000-000000000071")+			(effects+				(font+					(size 1 1)+					(thickness 0.15)+				)+			)+		)+		(attr smd)+		(fp_rect+			(start -3.45 -3.45)+			(end 3.45 3.45)+			(stroke+				(width 0.05)+				(type solid)+			)+			(fill no)+			(layer "F.CrtYd")+			(uuid "00000000-0000-4000-8000-000000000072")+		)+		(pad "" np_thru_hole circle+			(at 0 0)+			(size 3.2 3.2)+			(drill 3.2)+			(layers "*.Cu" "*.Mask")+			(uuid "00000000-0000-4000-8000-000000000073")+		)+		(embedded_fonts no)+	)+	(segment+		(start 18.05 19.675)+		(end 10 19.675)+		(width 0.2)+		(layer "F.Cu")+		(net "SIG_A")+		(uuid "00000000-0000-4000-8000-000000000074")+	)+	(segment+		(start 10 19.675)+		(end 10 28)+		(width 0.2)+		(layer "F.Cu")+		(net "SIG_A")+		(uuid "00000000-0000-4000-8000-000000000075")+	)+	(via+		(at 10 28)+		(size 0.6)+		(drill 0.3)+		(layers "F.Cu" "B.Cu")+		(net "SIG_A")+		(uuid "00000000-0000-4000-8000-000000000076")+	)+	(segment+		(start 10 28)+		(end 49.175 28)+		(width 0.25)+		(layer "B.Cu")+		(net "SIG_A")+		(uuid "00000000-0000-4000-8000-000000000077")+	)+	(gr_line+		(start 0 0)+		(end 60 0)+		(stroke+			(width 0.05)+			(type default)+		)+		(layer "Edge.Cuts")+		(uuid "00000000-0000-4000-8000-000000000078")+	)+	(gr_line+		(start 60 0)+		(end 60 40)+		(stroke+			(width 0.05)+			(type default)+		)+		(layer "Edge.Cuts")+		(uuid "00000000-0000-4000-8000-000000000079")+	)+	(gr_line+		(start 60 40)+		(end 0 40)+		(stroke+			(width 0.05)+			(type default)+		)+		(layer "Edge.Cuts")+		(uuid "00000000-0000-4000-8000-000000000080")+	)+	(gr_line+		(start 0 40)+		(end 0 0)+		(stroke+			(width 0.05)+			(type default)+		)+		(layer "Edge.Cuts")+		(uuid "00000000-0000-4000-8000-000000000081")+	)+	(embedded_fonts no)+)
rust/crates/kicad-core/tests/fixtures/konnect-route/route-fixture.kicad_proadded+362
@@ -0,0 +1,362 @@+{+  "board": {+    "3dviewports": [],+    "design_settings": {+      "defaults": {+        "apply_defaults_to_fp_barcodes": false,+        "apply_defaults_to_fp_dimensions": false,+        "apply_defaults_to_fp_fields": false,+        "apply_defaults_to_fp_shapes": false,+        "apply_defaults_to_fp_text": false,+        "board_outline_line_width": 0.05,+        "copper_line_width": 0.2,+        "copper_text_italic": false,+        "copper_text_size_h": 1.5,+        "copper_text_size_v": 1.5,+        "copper_text_thickness": 0.3,+        "copper_text_upright": false,+        "courtyard_line_width": 0.05,+        "dimension_precision": 4,+        "dimension_units": 3,+        "dimensions": {+          "arrow_length": 1270000,+          "extension_offset": 500000,+          "keep_text_aligned": true,+          "suppress_zeroes": true,+          "text_position": 0,+          "units_format": 0+        },+        "fab_line_width": 0.1,+        "fab_text_italic": false,+        "fab_text_size_h": 1.0,+        "fab_text_size_v": 1.0,+        "fab_text_thickness": 0.15,+        "fab_text_upright": false,+        "other_line_width": 0.1,+        "other_text_italic": false,+        "other_text_size_h": 1.0,+        "other_text_size_v": 1.0,+        "other_text_thickness": 0.15,+        "other_text_upright": false,+        "pads": {+          "drill": 0.8,+          "height": 1.27,+          "width": 2.54+        },+        "silk_line_width": 0.1,+        "silk_text_italic": false,+        "silk_text_size_h": 1.0,+        "silk_text_size_v": 1.0,+        "silk_text_thickness": 0.1,+        "silk_text_upright": false,+        "zones": {+          "min_clearance": 0.5+        }+      },+      "diff_pair_dimensions": [+        {+          "gap": 0.0,+          "via_gap": 0.0,+          "width": 0.0+        }+      ],+      "drc_exclusions": [],+      "meta": {+        "version": 2+      },+      "rule_severities": {+        "annular_width": "error",+        "clearance": "error",+        "connection_width": "warning",+        "copper_edge_clearance": "error",+        "copper_sliver": "warning",+        "courtyards_overlap": "error",+        "creepage": "error",+        "diff_pair_gap_out_of_range": "error",+        "diff_pair_uncoupled_length_too_long": "error",+        "drill_out_of_range": "error",+        "duplicate_footprints": "warning",+        "extra_footprint": "warning",+        "footprint": "error",+        "footprint_filters_mismatch": "ignore",+        "footprint_symbol_field_mismatch": "warning",+        "footprint_symbol_mismatch": "warning",+        "footprint_type_mismatch": "ignore",+        "hole_clearance": "error",+        "hole_to_hole": "warning",+        "holes_co_located": "warning",+        "invalid_outline": "error",+        "isolated_copper": "warning",+        "item_on_disabled_layer": "error",+        "items_not_allowed": "error",+        "length_out_of_range": "error",+        "lib_footprint_issues": "warning",+        "lib_footprint_mismatch": "warning",+        "malformed_courtyard": "error",+        "microvia_drill_out_of_range": "error",+        "mirrored_text_on_front_layer": "warning",+        "missing_courtyard": "ignore",+        "missing_footprint": "warning",+        "missing_tuning_profile": "warning",+        "net_conflict": "warning",+        "nonmirrored_text_on_back_layer": "warning",+        "npth_inside_courtyard": "error",+        "padstack": "warning",+        "pth_inside_courtyard": "error",+        "shorting_items": "error",+        "silk_edge_clearance": "warning",+        "silk_over_copper": "warning",+        "silk_overlap": "warning",+        "skew_out_of_range": "error",+        "solder_mask_bridge": "error",+        "starved_thermal": "error",+        "text_height": "warning",+        "text_on_edge_cuts": "error",+        "text_thickness": "warning",+        "through_hole_pad_without_hole": "error",+        "too_many_vias": "error",+        "track_angle": "error",+        "track_dangling": "warning",+        "track_not_centered_on_via": "ignore",+        "track_on_post_machined_layer": "error",+        "track_segment_length": "error",+        "track_width": "error",+        "tracks_crossing": "error",+        "tuning_profile_track_geometries": "ignore",+        "unconnected_items": "error",+        "unresolved_variable": "error",+        "via_dangling": "warning",+        "zones_intersect": "error"+      },+      "rules": {+        "max_error": 0.005,+        "min_clearance": 0.15,+        "min_connection": 0.0,+        "min_copper_edge_clearance": 0.5,+        "min_groove_width": 0.0,+        "min_hole_clearance": 0.25,+        "min_hole_to_hole": 0.25,+        "min_microvia_diameter": 0.2,+        "min_microvia_drill": 0.1,+        "min_resolved_spokes": 2,+        "min_silk_clearance": 0.0,+        "min_text_height": 0.8,+        "min_text_thickness": 0.08,+        "min_through_hole_diameter": 0.3,+        "min_track_width": 0.2,+        "min_via_annular_width": 0.1,+        "min_via_diameter": 0.5,+        "solder_mask_to_copper_clearance": 0.0,+        "use_height_for_length_calcs": true+      },+      "teardrop_options": [+        {+          "td_onpthpad": true,+          "td_onroundshapesonly": false,+          "td_onsmdpad": true,+          "td_ontrackend": false,+          "td_onvia": true+        }+      ],+      "teardrop_parameters": [+        {+          "td_allow_use_two_tracks": true,+          "td_curve_segcount": 0,+          "td_height_ratio": 1.0,+          "td_length_ratio": 0.5,+          "td_maxheight": 2.0,+          "td_maxlen": 1.0,+          "td_on_pad_in_zone": false,+          "td_target_name": "td_round_shape",+          "td_width_to_size_filter_ratio": 0.9+        },+        {+          "td_allow_use_two_tracks": true,+          "td_curve_segcount": 0,+          "td_height_ratio": 1.0,+          "td_length_ratio": 0.5,+          "td_maxheight": 2.0,+          "td_maxlen": 1.0,+          "td_on_pad_in_zone": false,+          "td_target_name": "td_rect_shape",+          "td_width_to_size_filter_ratio": 0.9+        },+        {+          "td_allow_use_two_tracks": true,+          "td_curve_segcount": 0,+          "td_height_ratio": 1.0,+          "td_length_ratio": 0.5,+          "td_maxheight": 2.0,+          "td_maxlen": 1.0,+          "td_on_pad_in_zone": false,+          "td_target_name": "td_track_end",+          "td_width_to_size_filter_ratio": 0.9+        }+      ],+      "track_widths": [+        0.0,+        0.2,+        0.3,+        0.5+      ],+      "tuning_pattern_settings": {+        "diff_pair_defaults": {+          "corner_radius_percentage": 80,+          "corner_style": 1,+          "max_amplitude": 1.0,+          "min_amplitude": 0.2,+          "single_sided": false,+          "spacing": 1.0+        },+        "diff_pair_skew_defaults": {+          "corner_radius_percentage": 80,+          "corner_style": 1,+          "max_amplitude": 1.0,+          "min_amplitude": 0.2,+          "single_sided": false,+          "spacing": 0.6+        },+        "single_track_defaults": {+          "corner_radius_percentage": 80,+          "corner_style": 1,+          "max_amplitude": 1.0,+          "min_amplitude": 0.2,+          "single_sided": false,+          "spacing": 0.6+        }+      },+      "via_dimensions": [+        {+          "diameter": 0.0,+          "drill": 0.0+        },+        {+          "diameter": 0.6,+          "drill": 0.3+        }+      ],+      "zones_allow_external_fillets": false+    },+    "ipc2581": {+      "bom_rev": "",+      "dist": "",+      "distpn": "",+      "internal_id": "",+      "mfg": "",+      "mpn": "",+      "sch_revision": ""+    },+    "layer_pairs": [],+    "layer_presets": [],+    "viewports": []+  },+  "boards": [],+  "component_class_settings": {+    "assignments": [],+    "meta": {+      "version": 0+    },+    "sheet_component_classes": {+      "enabled": false+    }+  },+  "cvpcb": {+    "equivalence_files": []+  },+  "libraries": {+    "pinned_footprint_libs": [+      "AdomRP2040"+    ],+    "pinned_symbol_libs": [+      "AdomRP2040"+    ]+  },+  "meta": {+    "filename": "route-fixture.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,+        "tuning_profile": "",+        "via_diameter": 0.6,+        "via_drill": 0.3,+        "wire_width": 6+      },+      {+        "bus_width": 12,+        "clearance": 0.15,+        "diff_pair_gap": 0.15,+        "diff_pair_via_gap": 0.25,+        "diff_pair_width": 0.2,+        "line_style": 0,+        "microvia_diameter": 0.3,+        "microvia_drill": 0.1,+        "name": "USB",+        "pcb_color": "rgba(0, 0, 0, 0.000)",+        "priority": 0,+        "schematic_color": "rgba(0, 0, 0, 0.000)",+        "track_width": 0.2,+        "tuning_profile": "",+        "via_diameter": 0.6,+        "via_drill": 0.3,+        "wire_width": 6+      }+    ],+    "meta": {+      "version": 5+    },+    "net_colors": null,+    "netclass_assignments": null,+    "netclass_patterns": [+      {+        "netclass": "USB",+        "pattern": "USB_*"+      }+    ]+  },+  "pcbnew": {+    "last_paths": {+      "idf": "",+      "netlist": "",+      "plot": "gerbers/",+      "specctra_dsn": "",+      "vrml": ""+    },+    "page_layout_descr_file": ""+  },+  "schematic": {+    "annotate_messages": "",+    "bus_aliases": {},+    "legacy_lib_dir": "",+    "legacy_lib_list": [],+    "top_level_sheets": [+      {+        "filename": "rp2040-breakout.kicad_sch",+        "name": "rp2040-breakout",+        "uuid": "00000000-0000-0000-0000-000000000000"+      }+    ]+  },+  "sheets": [],+  "text_variables": {},+  "tuning_profiles": {+    "meta": {+      "version": 0+    },+    "tuning_profiles_impedance_geometric": []+  }+}
rust/crates/kicad-core/tests/fixtures/konnect-route/route-fixture.netadded+88
@@ -0,0 +1,88 @@+<?xml version="1.0" encoding="UTF-8"?>+<export version="E">+  <design><source>route-fixture.kicad_sch</source><tool>Eeschema 10.0.0</tool></design>+  <components>+    <comp ref="U1"><value>MCU</value><footprint>Fixture:QFN-16_4x4mm_P0.65mm</footprint><libsource lib="Device" part="X" description=""/><sheetpath names="/" tstamps="/"/><tstamps>10000000-0000-4000-8000-000000000001</tstamps></comp>+    <comp ref="U2"><value>FPGA</value><footprint>Fixture:BGA-16_4x4_P0.8mm</footprint><libsource lib="Device" part="X" description=""/><sheetpath names="/" tstamps="/"/><tstamps>10000000-0000-4000-8000-000000000002</tstamps></comp>+    <comp ref="C1"><value>100n</value><footprint>Fixture:C_0402_1005Metric</footprint><libsource lib="Device" part="X" description=""/><sheetpath names="/" tstamps="/"/><tstamps>10000000-0000-4000-8000-000000000003</tstamps></comp>+    <comp ref="C2"><value>1u</value><footprint>Fixture:C_0402_1005Metric</footprint><libsource lib="Device" part="X" description=""/><sheetpath names="/" tstamps="/"/><tstamps>10000000-0000-4000-8000-000000000004</tstamps></comp>+    <comp ref="J1"><value>USB</value><footprint>Fixture:PinHeader_1x04_P2.00mm_Vertical</footprint><libsource lib="Device" part="X" description=""/><sheetpath names="/" tstamps="/"/><tstamps>10000000-0000-4000-8000-000000000005</tstamps></comp>+    <comp ref="R1"><value>10k</value><footprint>Fixture:R_0603_1608Metric</footprint><libsource lib="Device" part="X" description=""/><sheetpath names="/" tstamps="/"/><tstamps>10000000-0000-4000-8000-000000000006</tstamps></comp>+    <comp ref="R2"><value>4k7</value><footprint>Fixture:R_0603_1608Metric</footprint><libsource lib="Device" part="X" description=""/><sheetpath names="/" tstamps="/"/><tstamps>10000000-0000-4000-8000-000000000007</tstamps></comp>+  </components>+  <nets>+    <net code="1" name="+3V3" class="Default">+      <node ref="U1" pin="1" pintype="passive"/>+      <node ref="U1" pin="5" pintype="passive"/>+      <node ref="C1" pin="1" pintype="passive"/>+      <node ref="C2" pin="1" pintype="passive"/>+      <node ref="J1" pin="1" pintype="passive"/>+      <node ref="R2" pin="1" pintype="passive"/>+    </net>+    <net code="2" name="GND" class="Default">+      <node ref="U1" pin="17" pintype="passive"/>+      <node ref="C1" pin="2" pintype="passive"/>+      <node ref="C2" pin="2" pintype="passive"/>+      <node ref="J1" pin="4" pintype="passive"/>+      <node ref="U2" pin="A1" pintype="passive"/>+      <node ref="U2" pin="A4" pintype="passive"/>+      <node ref="U2" pin="D1" pintype="passive"/>+      <node ref="U2" pin="D4" pintype="passive"/>+    </net>+    <net code="3" name="SIG_A" class="Default">+      <node ref="U1" pin="2" pintype="passive"/>+      <node ref="R1" pin="1" pintype="passive"/>+    </net>+    <net code="4" name="SIG_B" class="Default">+      <node ref="U1" pin="9" pintype="passive"/>+      <node ref="R2" pin="2" pintype="passive"/>+    </net>+    <net code="5" name="SIG_C" class="Default">+      <node ref="R1" pin="2" pintype="passive"/>+    </net>+    <net code="6" name="USB_D+" class="Default">+      <node ref="U1" pin="3" pintype="passive"/>+      <node ref="J1" pin="2" pintype="passive"/>+    </net>+    <net code="7" name="USB_D-" class="Default">+      <node ref="U1" pin="4" pintype="passive"/>+      <node ref="J1" pin="3" pintype="passive"/>+    </net>+    <net code="8" name="BGA_A2" class="Default">+      <node ref="U2" pin="A2" pintype="passive"/>+    </net>+    <net code="9" name="BGA_A3" class="Default">+      <node ref="U2" pin="A3" pintype="passive"/>+    </net>+    <net code="10" name="BGA_B1" class="Default">+      <node ref="U2" pin="B1" pintype="passive"/>+    </net>+    <net code="11" name="BGA_B2" class="Default">+      <node ref="U2" pin="B2" pintype="passive"/>+    </net>+    <net code="12" name="BGA_B3" class="Default">+      <node ref="U2" pin="B3" pintype="passive"/>+    </net>+    <net code="13" name="BGA_B4" class="Default">+      <node ref="U2" pin="B4" pintype="passive"/>+    </net>+    <net code="14" name="BGA_C1" class="Default">+      <node ref="U2" pin="C1" pintype="passive"/>+    </net>+    <net code="15" name="BGA_C2" class="Default">+      <node ref="U2" pin="C2" pintype="passive"/>+    </net>+    <net code="16" name="BGA_C3" class="Default">+      <node ref="U2" pin="C3" pintype="passive"/>+    </net>+    <net code="17" name="BGA_C4" class="Default">+      <node ref="U2" pin="C4" pintype="passive"/>+    </net>+    <net code="18" name="BGA_D2" class="Default">+      <node ref="U2" pin="D2" pintype="passive"/>+    </net>+    <net code="19" name="BGA_D3" class="Default">+      <node ref="U2" pin="D3" pintype="passive"/>+    </net>+  </nets>+</export>
rust/crates/kicad-core/tests/fixtures/sch-edit/MiniLib.kicad_symadded+982
@@ -0,0 +1,982 @@+(kicad_symbol_lib+	(version 20251024)+	(generator "kicad_symbol_editor")+	(generator_version "10.0")+	(symbol "R"+		(pin_numbers+			(hide yes)+		)+		(pin_names+			(offset 0)+		)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(in_pos_files yes)+		(duplicate_pin_numbers_are_jumpers no)+		(property "Reference" "R"+			(at 2.032 0 90)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Value" "R"+			(at 0 0 90)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Footprint" ""+			(at -1.778 0 90)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Datasheet" ""+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Description" "Resistor"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_keywords" "R res resistor"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_fp_filters" "R_*"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(symbol "R_0_1"+			(rectangle+				(start -1.016 -2.54)+				(end 1.016 2.54)+				(stroke+					(width 0.254)+					(type default)+				)+				(fill+					(type none)+				)+			)+		)+		(symbol "R_1_1"+			(pin passive line+				(at 0 3.81 270)+				(length 1.27)+				(name ""+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "1"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+			(pin passive line+				(at 0 -3.81 90)+				(length 1.27)+				(name ""+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "2"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+		)+		(embedded_fonts no)+	)+	(symbol "R_Small"+		(pin_numbers+			(hide yes)+		)+		(pin_names+			(offset 0.254)+			(hide yes)+		)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(in_pos_files yes)+		(duplicate_pin_numbers_are_jumpers no)+		(property "Reference" "R"+			(at 0 0 90)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.016 1.016)+				)+			)+		)+		(property "Value" "R_Small"+			(at 1.778 0 90)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Footprint" ""+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Datasheet" ""+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Description" "Resistor, small symbol"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_keywords" "R resistor"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_fp_filters" "R_*"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(symbol "R_Small_0_1"+			(rectangle+				(start -0.762 1.778)+				(end 0.762 -1.778)+				(stroke+					(width 0.2032)+					(type default)+				)+				(fill+					(type none)+				)+			)+		)+		(symbol "R_Small_1_1"+			(pin passive line+				(at 0 2.54 270)+				(length 0.762)+				(name ""+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "1"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+			(pin passive line+				(at 0 -2.54 90)+				(length 0.762)+				(name ""+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "2"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+		)+		(embedded_fonts no)+	)+	(symbol "C"+		(pin_numbers+			(hide yes)+		)+		(pin_names+			(offset 0.254)+		)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(in_pos_files yes)+		(duplicate_pin_numbers_are_jumpers no)+		(property "Reference" "C"+			(at 0.635 2.54 0)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "C"+			(at 0.635 -2.54 0)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 0.9652 -3.81 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Datasheet" ""+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Description" "Unpolarized capacitor"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_keywords" "cap capacitor"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_fp_filters" "C_*"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(symbol "C_0_1"+			(polyline+				(pts+					(xy -2.032 0.762) (xy 2.032 0.762)+				)+				(stroke+					(width 0.508)+					(type default)+				)+				(fill+					(type none)+				)+			)+			(polyline+				(pts+					(xy -2.032 -0.762) (xy 2.032 -0.762)+				)+				(stroke+					(width 0.508)+					(type default)+				)+				(fill+					(type none)+				)+			)+		)+		(symbol "C_1_1"+			(pin passive line+				(at 0 3.81 270)+				(length 2.794)+				(name ""+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "1"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+			(pin passive line+				(at 0 -3.81 90)+				(length 2.794)+				(name ""+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "2"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+		)+		(embedded_fonts no)+	)+	(symbol "D"+		(pin_numbers+			(hide yes)+		)+		(pin_names+			(offset 1.016)+			(hide yes)+		)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(in_pos_files yes)+		(duplicate_pin_numbers_are_jumpers no)+		(property "Reference" "D"+			(at 0 2.54 0)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Value" "D"+			(at 0 -2.54 0)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Footprint" ""+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Datasheet" ""+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Description" "Diode"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Sim.Device" "D"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Sim.Pins" "1=K 2=A"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_keywords" "diode"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_fp_filters" "TO-???* *_Diode_* *SingleDiode* D_*"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(symbol "D_0_1"+			(polyline+				(pts+					(xy -1.27 1.27) (xy -1.27 -1.27)+				)+				(stroke+					(width 0.254)+					(type default)+				)+				(fill+					(type none)+				)+			)+			(polyline+				(pts+					(xy 1.27 1.27) (xy 1.27 -1.27) (xy -1.27 0) (xy 1.27 1.27)+				)+				(stroke+					(width 0.254)+					(type default)+				)+				(fill+					(type none)+				)+			)+			(polyline+				(pts+					(xy 1.27 0) (xy -1.27 0)+				)+				(stroke+					(width 0)+					(type default)+				)+				(fill+					(type none)+				)+			)+		)+		(symbol "D_1_1"+			(pin passive line+				(at -3.81 0 0)+				(length 2.54)+				(name "K"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "1"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+			(pin passive line+				(at 3.81 0 180)+				(length 2.54)+				(name "A"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "2"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+		)+		(embedded_fonts no)+	)+	(symbol "LM2904"+		(pin_names+			(offset 0.127)+		)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(in_pos_files yes)+		(duplicate_pin_numbers_are_jumpers no)+		(property "Reference" "U"+			(at 0 5.08 0)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "LM2904"+			(at 0 -5.08 0)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Datasheet" "http://www.ti.com/lit/ds/symlink/lm358.pdf"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Description" "Dual Operational Amplifiers, DIP-8/SOIC-8/TSSOP-8/VSSOP-8"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_locked" ""+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_keywords" "dual opamp"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_fp_filters" "SOIC*3.9x4.9mm*P1.27mm* DIP*W7.62mm* TO*99* OnSemi*Micro8* TSSOP*3x3mm*P0.65mm* TSSOP*4.4x3mm*P0.65mm* MSOP*3x3mm*P0.65mm* SSOP*3.9x4.9mm*P0.635mm* LFCSP*2x2mm*P0.5mm* *SIP* SOIC*5.3x6.2mm*P1.27mm*"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(symbol "LM2904_1_1"+			(polyline+				(pts+					(xy -5.08 5.08) (xy 5.08 0) (xy -5.08 -5.08) (xy -5.08 5.08)+				)+				(stroke+					(width 0.254)+					(type default)+				)+				(fill+					(type background)+				)+			)+			(pin output line+				(at 7.62 0 180)+				(length 2.54)+				(name ""+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "1"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+			(pin input line+				(at -7.62 -2.54 0)+				(length 2.54)+				(name "-"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "2"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+			(pin input line+				(at -7.62 2.54 0)+				(length 2.54)+				(name "+"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "3"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+		)+		(symbol "LM2904_2_1"+			(polyline+				(pts+					(xy -5.08 5.08) (xy 5.08 0) (xy -5.08 -5.08) (xy -5.08 5.08)+				)+				(stroke+					(width 0.254)+					(type default)+				)+				(fill+					(type background)+				)+			)+			(pin input line+				(at -7.62 2.54 0)+				(length 2.54)+				(name "+"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "5"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+			(pin input line+				(at -7.62 -2.54 0)+				(length 2.54)+				(name "-"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "6"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+			(pin output line+				(at 7.62 0 180)+				(length 2.54)+				(name ""+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "7"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+		)+		(symbol "LM2904_3_1"+			(pin power_in line+				(at -2.54 -7.62 90)+				(length 3.81)+				(name "V-"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "4"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+			(pin power_in line+				(at -2.54 7.62 270)+				(length 3.81)+				(name "V+"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+				(number "8"+					(effects+						(font+							(size 1.27 1.27)+						)+					)+				)+			)+		)+		(embedded_fonts no)+	)+	(symbol "LM358"+		(extends "LM2904")+		(property "Reference" "U"+			(at 0 5.08 0)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "LM358"+			(at 0 -5.08 0)+			(show_name no)+			(do_not_autoplace no)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Datasheet" "http://www.ti.com/lit/ds/symlink/lm2904-n.pdf"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "Description" "Low-Power, Dual Operational Amplifiers, DIP-8/SOIC-8/TO-99-8"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_keywords" "dual opamp"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(property "ki_fp_filters" "SOIC*3.9x4.9mm*P1.27mm* DIP*W7.62mm* TO*99* OnSemi*Micro8* TSSOP*3x3mm*P0.65mm* TSSOP*4.4x3mm*P0.65mm* MSOP*3x3mm*P0.65mm* SSOP*3.9x4.9mm*P0.635mm* LFCSP*2x2mm*P0.5mm* *SIP* SOIC*5.3x6.2mm*P1.27mm*"+			(at 0 0 0)+			(show_name no)+			(do_not_autoplace no)+			(hide yes)+			(effects+				(font+					(size 1.27 1.27)+				)+			)+		)+		(embedded_fonts no)+	)+)
rust/crates/kicad-core/tests/fixtures/sch-edit/edit10.kicad_schadded+1659
@@ -0,0 +1,1659 @@+(kicad_sch+	(version 20260306)+	(generator "eeschema")+	(generator_version "10.0")+	(uuid "5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3")+	(paper "A4")+	(lib_symbols+		(symbol "Device:R"+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 0)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "R"+				(at 2.032 0 90)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Value" "R"+				(at 0 0 90)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Footprint" ""+				(at -1.778 0 90)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Resistor"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "R res resistor"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_fp_filters" "R_*"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "R_0_1"+				(rectangle+					(start -1.016 -2.54)+					(end 1.016 2.54)+					(stroke+						(width 0.254)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "R_1_1"+				(pin passive line+					(at 0 3.81 270)+					(length 1.27)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin passive line+					(at 0 -3.81 90)+					(length 1.27)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "2"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+		(symbol "Device:C"+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 0.254)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "C"+				(at 0.635 2.54 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+					(justify left)+				)+			)+			(property "Value" "C"+				(at 0.635 -2.54 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+					(justify left)+				)+			)+			(property "Footprint" ""+				(at 0.9652 -3.81 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Unpolarized capacitor"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "cap capacitor"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_fp_filters" "C_*"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "C_0_1"+				(polyline+					(pts+						(xy -2.032 0.762) (xy 2.032 0.762)+					)+					(stroke+						(width 0.508)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy -2.032 -0.762) (xy 2.032 -0.762)+					)+					(stroke+						(width 0.508)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "C_1_1"+				(pin passive line+					(at 0 3.81 270)+					(length 2.794)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin passive line+					(at 0 -3.81 90)+					(length 2.794)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "2"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+		(symbol "Amplifier_Operational:LM358"+			(pin_names+				(offset 0.127)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "U"+				(at 0 5.08 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+					(justify left)+				)+			)+			(property "Value" "LM358"+				(at 0 -5.08 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+					(justify left)+				)+			)+			(property "Footprint" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" "http://www.ti.com/lit/ds/symlink/lm2904-n.pdf"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Low-Power, Dual Operational Amplifiers, DIP-8/SOIC-8/TO-99-8"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_locked" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "dual opamp"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_fp_filters" "SOIC*3.9x4.9mm*P1.27mm* DIP*W7.62mm* TO*99* OnSemi*Micro8* TSSOP*3x3mm*P0.65mm* TSSOP*4.4x3mm*P0.65mm* MSOP*3x3mm*P0.65mm* SSOP*3.9x4.9mm*P0.635mm* LFCSP*2x2mm*P0.5mm* *SIP* SOIC*5.3x6.2mm*P1.27mm*"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "LM358_1_1"+				(polyline+					(pts+						(xy -5.08 5.08) (xy 5.08 0) (xy -5.08 -5.08) (xy -5.08 5.08)+					)+					(stroke+						(width 0.254)+						(type default)+					)+					(fill+						(type background)+					)+				)+				(pin output line+					(at 7.62 0 180)+					(length 2.54)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin input line+					(at -7.62 -2.54 0)+					(length 2.54)+					(name "-"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "2"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin input line+					(at -7.62 2.54 0)+					(length 2.54)+					(name "+"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "3"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(symbol "LM358_2_1"+				(polyline+					(pts+						(xy -5.08 5.08) (xy 5.08 0) (xy -5.08 -5.08) (xy -5.08 5.08)+					)+					(stroke+						(width 0.254)+						(type default)+					)+					(fill+						(type background)+					)+				)+				(pin input line+					(at -7.62 2.54 0)+					(length 2.54)+					(name "+"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "5"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin input line+					(at -7.62 -2.54 0)+					(length 2.54)+					(name "-"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "6"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin output line+					(at 7.62 0 180)+					(length 2.54)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "7"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(symbol "LM358_3_1"+				(pin power_in line+					(at -2.54 -7.62 90)+					(length 3.81)+					(name "V-"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "4"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin power_in line+					(at -2.54 7.62 270)+					(length 3.81)+					(name "V+"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "8"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+		(symbol "power:GND"+			(power global)+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 0)+				(hide yes)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "#PWR"+				(at 0 -6.35 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Value" "GND"+				(at 0 -3.81 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Footprint" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Power symbol creates a global label with name \"GND\" , ground"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "global power"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "GND_0_1"+				(polyline+					(pts+						(xy 0 0) (xy 0 -1.27) (xy 1.27 -1.27) (xy 0 -2.54) (xy -1.27 -1.27) (xy 0 -1.27)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "GND_1_1"+				(pin power_in line+					(at 0 0 270)+					(length 0)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+	)+	(symbol+		(lib_id "Device:R")+		(at 100.33 50.8 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "de241243-e81d-4d9c-953a-5bc7a7f03b8c")+		(property "Reference" "R1"+			(at 102.87 49.53 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "10k"+			(at 102.87 52.07 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 100.33 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 100.33 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Resistor"+			(at 100.33 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "86346bd8-b816-4565-9337-74d67aeb0454")+		)+		(pin "2"+			(uuid "63471aa7-3191-4c0c-b13e-35ee074e42d2")+		)+		(instances+			(project "edit10"+				(path "/5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3"+					(reference "R1")+					(unit 1)+				)+			)+		)+	)+	(symbol+		(lib_id "Device:R")+		(at 130.81 50.8 90)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "363b8790-a66f-4d16-be94-3dc788051cfa")+		(property "Reference" "R2"+			(at 133.35 49.53 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "4k7"+			(at 133.35 52.07 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 130.81 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 130.81 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Resistor"+			(at 130.81 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "cb838114-58b8-4489-a745-82b0159777cc")+		)+		(pin "2"+			(uuid "b400ca2c-9b0d-43cf-9c87-6acd05ff10ab")+		)+		(instances+			(project "edit10"+				(path "/5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3"+					(reference "R2")+					(unit 1)+				)+			)+		)+	)+	(symbol+		(lib_id "Device:C")+		(at 100.33 81.28 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "13d8f0c5-544b-4327-abee-f56f95650bdd")+		(property "Reference" "C1"+			(at 102.87 80.01 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "100n"+			(at 102.87 82.55 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 100.33 81.28 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 100.33 81.28 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Unpolarized capacitor"+			(at 100.33 81.28 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "fdd89bbd-e9c8-4a94-bc96-2cc11856e028")+		)+		(pin "2"+			(uuid "0bc48ce9-d4a9-48b7-b5fe-69159dae3359")+		)+		(instances+			(project "edit10"+				(path "/5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3"+					(reference "C1")+					(unit 1)+				)+			)+		)+	)+	(symbol+		(lib_id "Amplifier_Operational:LM358")+		(at 152.4 81.28 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "a778fb8d-7d8e-426a-9213-dbe7d0a3fbac")+		(property "Reference" "U1"+			(at 154.94 80.01 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "LM358"+			(at 154.94 82.55 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 152.4 81.28 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" "http://www.ti.com/lit/ds/symlink/lm2904-n.pdf"+			(at 152.4 81.28 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Low-Power, Dual Operational Amplifiers, DIP-8/SOIC-8/TO-99-8"+			(at 152.4 81.28 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "22c790c2-f5f5-466a-a626-6dd1de6837ae")+		)+		(pin "2"+			(uuid "df223f8c-01d9-4750-8901-e5510702f7e0")+		)+		(pin "3"+			(uuid "661f04bc-6e88-4ed3-88a1-b6f5a7083534")+		)+		(instances+			(project "edit10"+				(path "/5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3"+					(reference "U1")+					(unit 1)+				)+			)+		)+	)+	(symbol+		(lib_id "Amplifier_Operational:LM358")+		(at 152.4 109.22 0)+		(unit 2)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "4df20f4c-2e1a-41f9-8ba8-39e3746ca052")+		(property "Reference" "U1"+			(at 154.94 107.95 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "LM358"+			(at 154.94 110.49 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 152.4 109.22 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" "http://www.ti.com/lit/ds/symlink/lm2904-n.pdf"+			(at 152.4 109.22 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Low-Power, Dual Operational Amplifiers, DIP-8/SOIC-8/TO-99-8"+			(at 152.4 109.22 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "5"+			(uuid "933abf97-e952-4da6-a16a-b7080004f144")+		)+		(pin "6"+			(uuid "d31eae36-9a3e-48d7-b385-8f6aec3cafdc")+		)+		(pin "7"+			(uuid "52f9cc7f-135f-4280-b4cb-3b71525b7eb9")+		)+		(instances+			(project "edit10"+				(path "/5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3"+					(reference "U1")+					(unit 2)+				)+			)+		)+	)+	(symbol+		(lib_id "Amplifier_Operational:LM358")+		(at 182.88 95.25 0)+		(unit 3)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "e74aec45-7420-4544-9a16-291b7adb9227")+		(property "Reference" "U1"+			(at 185.42 93.98 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "LM358"+			(at 185.42 96.52 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 182.88 95.25 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" "http://www.ti.com/lit/ds/symlink/lm2904-n.pdf"+			(at 182.88 95.25 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Low-Power, Dual Operational Amplifiers, DIP-8/SOIC-8/TO-99-8"+			(at 182.88 95.25 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "4"+			(uuid "4e82aa14-ea85-41e0-a3c6-5fdf2ecb4170")+		)+		(pin "8"+			(uuid "870f8b7e-db2c-4981-b5e6-a338dc1737ad")+		)+		(instances+			(project "edit10"+				(path "/5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3"+					(reference "U1")+					(unit 3)+				)+			)+		)+	)+	(symbol+		(lib_id "power:GND")+		(at 100.33 88.9 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "b70504ec-7905-4e06-be12-79bdaafe2bc3")+		(property "Reference" "#PWR01"+			(at 102.87 87.63 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+				(hide yes)+			)+		)+		(property "Value" "GND"+			(at 102.87 90.17 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 100.33 88.9 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 100.33 88.9 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Power symbol creates a global label with name \"GND\" , ground"+			(at 100.33 88.9 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "5dfd2a02-7e83-457b-b3d6-94730f992eec")+		)+		(instances+			(project "edit10"+				(path "/5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3"+					(reference "#PWR01")+					(unit 1)+				)+			)+		)+	)+	(symbol+		(lib_id "Device:R")+		(at 63.5 101.6 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "8c53d5e6-f2b3-4796-92e5-cc4a9d73c5b0")+		(property "Reference" "R?"+			(at 66.04 100.33 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "1k"+			(at 66.04 102.87 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 63.5 101.6 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 63.5 101.6 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Resistor"+			(at 63.5 101.6 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "a8383ae8-92b6-4945-be7b-fc61723b74df")+		)+		(pin "2"+			(uuid "2eb719d3-94c7-4b4d-95ed-c3ee9ee9b7cc")+		)+		(instances+			(project "edit10"+				(path "/5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3"+					(reference "R?")+					(unit 1)+				)+			)+		)+	)+	(symbol+		(lib_id "Device:R")+		(at 50.8 101.6 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "1b0b63ac-f91d-43ec-85b0-6d9a2c80125c")+		(property "Reference" "R?"+			(at 53.34 100.33 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "1k"+			(at 53.34 102.87 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 50.8 101.6 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 50.8 101.6 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Resistor"+			(at 50.8 101.6 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "4dfda514-653c-419f-aa07-023ad0eee714")+		)+		(pin "2"+			(uuid "3162ea45-2965-480e-bc80-f7af692628b2")+		)+		(instances+			(project "edit10"+				(path "/5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3"+					(reference "R?")+					(unit 1)+				)+			)+		)+	)+	(symbol+		(lib_id "Device:C")+		(at 50.8 127 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "97902608-8861-41f2-b571-3a59d20b0073")+		(property "Reference" "C?"+			(at 53.34 125.73 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "1u"+			(at 53.34 128.27 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 50.8 127 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 50.8 127 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Unpolarized capacitor"+			(at 50.8 127 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "6e9fb910-952a-4dbf-85f0-f0c6b466859d")+		)+		(pin "2"+			(uuid "8bf60025-4fd0-4559-87cb-8340bfe74da3")+		)+		(instances+			(project "edit10"+				(path "/5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3"+					(reference "C?")+					(unit 1)+				)+			)+		)+	)+	(symbol+		(lib_id "Amplifier_Operational:LM358")+		(at 101.6 139.7 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "7a6b2ef3-86b4-4dcd-940b-22c95d6ca2b6")+		(property "Reference" "U?"+			(at 104.14 138.43 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "LM358"+			(at 104.14 140.97 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 101.6 139.7 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" "http://www.ti.com/lit/ds/symlink/lm2904-n.pdf"+			(at 101.6 139.7 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Low-Power, Dual Operational Amplifiers, DIP-8/SOIC-8/TO-99-8"+			(at 101.6 139.7 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "ce70a63c-64f7-48f4-b1d7-a6d09a13fe77")+		)+		(pin "2"+			(uuid "8cc65bed-b248-4fa3-92fa-7f6ee374ad5c")+		)+		(pin "3"+			(uuid "d960c7d7-5262-47f1-8742-8da67a4c79ae")+		)+		(instances+			(project "edit10"+				(path "/5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3"+					(reference "U?")+					(unit 1)+				)+			)+		)+	)+	(symbol+		(lib_id "Amplifier_Operational:LM358")+		(at 127 139.7 0)+		(unit 2)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "9ec19ae7-2f49-4c8f-b7ae-b2d0829bcbb7")+		(property "Reference" "U?"+			(at 129.54 138.43 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "LM358"+			(at 129.54 140.97 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 127 139.7 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" "http://www.ti.com/lit/ds/symlink/lm2904-n.pdf"+			(at 127 139.7 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Low-Power, Dual Operational Amplifiers, DIP-8/SOIC-8/TO-99-8"+			(at 127 139.7 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "5"+			(uuid "55c07c65-3f25-41df-a8a2-26c2af4445cf")+		)+		(pin "6"+			(uuid "e431660f-a46d-4dbf-822b-2bc239ff9458")+		)+		(pin "7"+			(uuid "6d3f8548-cdfd-487b-985b-ac369b058817")+		)+		(instances+			(project "edit10"+				(path "/5a0c1d2e-3f40-4a5b-8c6d-7e8f90a1b2c3"+					(reference "U?")+					(unit 2)+				)+			)+		)+	)+	(wire+		(pts+			(xy 100.33 54.61) (xy 100.33 60.96)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "511beb3a-f410-432f-b705-262be3b62d80")+	)+	(wire+		(pts+			(xy 100.33 60.96) (xy 120.65 60.96)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "a64877b9-2162-42c3-be37-0bc2cc5e6e64")+	)+	(wire+		(pts+			(xy 110.49 60.96) (xy 110.49 71.12)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "1e3ba12b-2399-4a9e-ab87-82a1347d23f3")+	)+	(label "VOUT"+		(at 120.65 60.96 0)+		(effects+			(font+				(size 1.27 1.27)+			)+			(justify left bottom)+		)+		(uuid "77e72d59-92d9-467c-9718-af9767c0c938")+	)+	(label "VOUT"+		(at 110.49 71.12 270)+		(effects+			(font+				(size 1.27 1.27)+			)+			(justify right bottom)+		)+		(uuid "c37a4fc2-6a9e-4e0a-a8cf-f59d67164158")+	)+	(wire+		(pts+			(xy 100.33 85.09) (xy 100.33 88.9)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "5d4ff788-ea5e-4550-9460-e350512c8dd7")+	)+	(global_label "SDA"+		(shape bidirectional)+		(at 63.5 60.96 0)+		(fields_autoplaced yes)+		(effects+			(font+				(size 1.27 1.27)+			)+			(justify left)+		)+		(uuid "04ef6bc2-f484-45db-8324-8106d93d90b9")+		(property "Intersheetrefs" "${INTERSHEET_REFS}"+			(at 63.5 60.96 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+	)+	(no_connect+		(at 127 50.8)+		(uuid "a1cc731e-fc81-423a-82ee-9d39554c3069")+	)+	(junction+		(at 110.49 60.96)+		(diameter 0)+		(color 0 0 0 0)+		(uuid "0f1e2d3c-4b5a-4968-8776-a5b4c3d2e1f0")+	)+	(bus+		(pts+			(xy 203.2 50.8) (xy 203.2 101.6)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d")+	)+	(text "edit fixture"+		(exclude_from_sim no)+		(at 50.8 30.48 0)+		(effects+			(font+				(size 1.27 1.27)+			)+			(justify left bottom)+		)+		(uuid "2b3c4d5e-6f70-4b8c-9dae-bf0c1d2e3f40")+	)+	(sheet_instances+		(path "/"+			(page "1")+		)+	)+	(embedded_fonts no)+)
rust/crates/kicad-core/tests/fixtures/sch_hier/checks.kicad_schadded+1712
@@ -0,0 +1,1712 @@+(kicad_sch+	(version 20260306)+	(generator "eeschema")+	(generator_version "10.0")+	(uuid "a0017973-c002-4164-a9dd-9a4bf75c5621")+	(paper "A4")+	(title_block+		(title "Checks fixture")+		(date "2026-10-27")+		(rev "A")+	)+	(lib_symbols+		(symbol "Device:R"+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 0)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "R"+				(at 2.032 0 90)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Value" "R"+				(at 0 0 90)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Footprint" ""+				(at -1.778 0 90)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Resistor"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "R res resistor"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_fp_filters" "R_*"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "R_0_1"+				(rectangle+					(start -1.016 -2.54)+					(end 1.016 2.54)+					(stroke+						(width 0.254)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "R_1_1"+				(pin passive line+					(at 0 3.81 270)+					(length 1.27)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin passive line+					(at 0 -3.81 90)+					(length 1.27)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "2"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+		(symbol "Regulator_Linear:AP2112K-3.3"+			(pin_names+				(offset 0.254)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "U"+				(at -5.08 5.715 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+					(justify left)+				)+			)+			(property "Value" "AP2112K-3.3"+				(at 0 5.715 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+					(justify left)+				)+			)+			(property "Footprint" "Package_TO_SOT_SMD:SOT-23-5"+				(at 0 8.255 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" "https://www.diodes.com/assets/Datasheets/AP2112.pdf"+				(at 0 2.54 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "600mA low dropout linear regulator, with enable pin, 3.8V-6V input voltage range, 3.3V fixed positive output, SOT-23-5"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "linear regulator ldo fixed positive"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_fp_filters" "SOT?23?5*"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "AP2112K-3.3_0_1"+				(rectangle+					(start -5.08 4.445)+					(end 5.08 -5.08)+					(stroke+						(width 0.254)+						(type default)+					)+					(fill+						(type background)+					)+				)+			)+			(symbol "AP2112K-3.3_1_1"+				(pin power_in line+					(at -7.62 2.54 0)+					(length 2.54)+					(name "VIN"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin power_in line+					(at 0 -7.62 90)+					(length 2.54)+					(name "GND"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "2"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin input line+					(at -7.62 0 0)+					(length 2.54)+					(name "EN"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "3"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin no_connect line+					(at 5.08 0 180)+					(length 2.54)+					(hide yes)+					(name "NC"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "4"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin power_out line+					(at 7.62 2.54 180)+					(length 2.54)+					(name "VOUT"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "5"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+		(symbol "power:+5V"+			(power global)+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 0)+				(hide yes)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "#PWR"+				(at 0 -3.81 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Value" "+5V"+				(at 0 3.556 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Footprint" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Power symbol creates a global label with name \"+5V\""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "global power"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "+5V_0_1"+				(polyline+					(pts+						(xy -0.762 1.27) (xy 0 2.54)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy 0 2.54) (xy 0.762 1.27)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy 0 0) (xy 0 2.54)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "+5V_1_1"+				(pin power_in line+					(at 0 0 90)+					(length 0)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+		(symbol "power:GND"+			(power global)+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 0)+				(hide yes)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "#PWR"+				(at 0 -6.35 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Value" "GND"+				(at 0 -3.81 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Footprint" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Power symbol creates a global label with name \"GND\" , ground"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "global power"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "GND_0_1"+				(polyline+					(pts+						(xy 0 0) (xy 0 -1.27) (xy 1.27 -1.27) (xy 0 -2.54) (xy -1.27 -1.27) (xy 0 -1.27)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "GND_1_1"+				(pin power_in line+					(at 0 0 270)+					(length 0)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+		(symbol "power:+3V3"+			(power global)+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 0)+				(hide yes)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "#PWR"+				(at 0 -3.81 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Value" "+3V3"+				(at 0 3.556 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Footprint" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Power symbol creates a global label with name \"+3V3\""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "global power"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "+3V3_0_1"+				(polyline+					(pts+						(xy -0.762 1.27) (xy 0 2.54)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy 0 2.54) (xy 0.762 1.27)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy 0 0) (xy 0 2.54)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "+3V3_1_1"+				(pin power_in line+					(at 0 0 90)+					(length 0)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+	)+	(symbol+		(lib_id "Device:R")+		(at 76.2 46.99 90)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "53d5fea0-5151-4d92-b029-8ddf2fea4cee")+		(property "Reference" "R2"+			(at 78.74 45.72 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "1k"+			(at 78.74 48.26 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 76.2 46.99 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 76.2 46.99 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Resistor"+			(at 76.2 46.99 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "609da665-481d-4d9c-a36b-ab3e35c0431c")+		)+		(pin "2"+			(uuid "1f2ffb75-7293-46f6-9e92-3fab6500c126")+		)+		(instances+			(project "checks"+				(path "/a0017973-c002-4164-a9dd-9a4bf75c5621"+					(reference "R2")+					(unit 1)+				)+			)+		)+	)+	(wire+		(pts+			(xy 72.39 46.99) (xy 62.23 46.99)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "0bb1474a-83ec-48e3-9593-fe6c68c7b950")+	)+	(label "FLOAT"+		(at 101.6 30.48 0)+		(effects+			(font+				(size 1.27 1.27)+			)+			(justify left bottom)+		)+		(uuid "9bbff019-fcfa-45aa-b815-cdec2422ef86")+	)+	(symbol+		(lib_id "Device:R")+		(at 127 50.8 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "3c695539-cac9-4a2b-921e-7a4c6c4769e3")+		(property "Reference" "R3"+			(at 129.54 49.53 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "1k"+			(at 129.54 52.07 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 127 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 127 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Resistor"+			(at 127 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "542ac0e5-a636-4b01-8e55-76d0f515dbf9")+		)+		(pin "2"+			(uuid "ff08c5cd-2112-481e-a13b-f52e1402dc63")+		)+		(instances+			(project "checks"+				(path "/a0017973-c002-4164-a9dd-9a4bf75c5621"+					(reference "R3")+					(unit 1)+				)+			)+		)+	)+	(wire+		(pts+			(xy 127 40.64) (xy 127 46.61)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "966ca3c9-8b7c-4c10-a972-7161e9f81697")+	)+	(label "NEAR"+		(at 127 40.64 0)+		(effects+			(font+				(size 1.27 1.27)+			)+			(justify left bottom)+		)+		(uuid "f86722a7-468c-4465-8caf-92042d12fdd2")+	)+	(wire+		(pts+			(xy 127 54.61) (xy 127 58.42)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "542949c5-5504-4556-9525-097be76b30a6")+	)+	(label "NEAR"+		(at 127 58.42 0)+		(effects+			(font+				(size 1.27 1.27)+			)+			(justify left bottom)+		)+		(uuid "f38b34ea-adb6-433b-b467-3b635af4898e")+	)+	(symbol+		(lib_id "Device:R")+		(at 152.4 50.8 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "7fe092f7-b888-4ba5-9b72-da05e475ac10")+		(property "Reference" "R4"+			(at 154.94 49.53 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "1k"+			(at 154.94 52.07 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 152.4 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 152.4 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Resistor"+			(at 152.4 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "5a54b209-26b0-4abc-a2ce-105b5027a01f")+		)+		(pin "2"+			(uuid "6c73d426-1c34-4d44-a799-3d8f68032da6")+		)+		(instances+			(project "checks"+				(path "/a0017973-c002-4164-a9dd-9a4bf75c5621"+					(reference "R4")+					(unit 1)+				)+			)+		)+	)+	(symbol+		(lib_id "Device:R")+		(at 153.67 52.07 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "06658ee1-45cc-482e-bea2-c3c4c957f737")+		(property "Reference" "R5"+			(at 156.21 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "1k"+			(at 156.21 53.34 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 153.67 52.07 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 153.67 52.07 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Resistor"+			(at 153.67 52.07 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "2d204fed-d9fd-4167-ba59-215f6668a66c")+		)+		(pin "2"+			(uuid "67059f0b-67d6-41f0-938a-ae9e97e9a6bb")+		)+		(instances+			(project "checks"+				(path "/a0017973-c002-4164-a9dd-9a4bf75c5621"+					(reference "R5")+					(unit 1)+				)+			)+		)+	)+	(text "NOTE"+		(exclude_from_sim no)+		(at 153.67 52.07 0)+		(effects+			(font+				(size 1.27 1.27)+			)+		)+		(uuid "eff7afdf-243d-4519-8f61-9d00775ebd0d")+	)+	(wire+		(pts+			(xy 50.8 76.2) (xy 76.2 76.2)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "a3f81102-c617-447a-b36a-cc262a22f336")+	)+	(label "NETA"+		(at 50.8 76.2 0)+		(effects+			(font+				(size 1.27 1.27)+			)+			(justify left bottom)+		)+		(uuid "d47f1770-1cbf-4bca-b701-6e3817dfb9c0")+	)+	(label "NETB"+		(at 76.2 76.2 0)+		(effects+			(font+				(size 1.27 1.27)+			)+			(justify left bottom)+		)+		(uuid "49897880-527d-414f-8759-086c8cf221c2")+	)+	(symbol+		(lib_id "Device:R")+		(at 60.96 80.01 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "c7d3c8de-02b7-43e9-be58-0393b2410879")+		(property "Reference" "R6"+			(at 63.5 78.74 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "1k"+			(at 63.5 81.28 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 60.96 80.01 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 60.96 80.01 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Resistor"+			(at 60.96 80.01 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "fab61c89-1aec-461e-a033-eb8a0c4267ad")+		)+		(pin "2"+			(uuid "29901a5c-1f71-416c-afc7-0a48d89b9e8b")+		)+		(instances+			(project "checks"+				(path "/a0017973-c002-4164-a9dd-9a4bf75c5621"+					(reference "R6")+					(unit 1)+				)+			)+		)+	)+	(junction+		(at 60.96 76.2)+		(diameter 0)+		(color 0 0 0 0)+		(uuid "c8630ea3-d190-4945-9c23-e4ffc6cbf970")+	)+	(symbol+		(lib_id "Device:R")+		(at 68.58 80.01 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "05259d86-c459-45b5-8acb-d51bb2337f3c")+		(property "Reference" "R7"+			(at 71.12 78.74 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "1k"+			(at 71.12 81.28 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 68.58 80.01 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 68.58 80.01 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Resistor"+			(at 68.58 80.01 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "469392db-571c-489c-876f-ab7ea3796651")+		)+		(pin "2"+			(uuid "60dc97f0-4880-4d1a-a702-51b547c60dcf")+		)+		(instances+			(project "checks"+				(path "/a0017973-c002-4164-a9dd-9a4bf75c5621"+					(reference "R7")+					(unit 1)+				)+			)+		)+	)+	(junction+		(at 68.58 76.2)+		(diameter 0)+		(color 0 0 0 0)+		(uuid "6eafab5d-fdfb-45b9-89fa-c91c5bd665f7")+	)+	(wire+		(pts+			(xy 60.96 83.82) (xy 60.96 88.9)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "61970795-5c04-40f3-8139-7836f2603539")+	)+	(wire+		(pts+			(xy 60.96 88.9) (xy 68.58 88.9)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "a774dfa6-59f0-4d20-9375-1f84f330299c")+	)+	(wire+		(pts+			(xy 68.58 88.9) (xy 68.58 83.82)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "7edba83e-2f59-48aa-b759-ff97adee436c")+	)+	(symbol+		(lib_id "Device:R")+		(at 50.8 50.8 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "5b94f367-d980-404d-a109-87bb6e35a02f")+		(property "Reference" "R1"+			(at 53.34 49.53 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "1k"+			(at 53.34 52.07 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 50.8 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 50.8 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Resistor"+			(at 50.8 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "ae6bcc1c-00b9-4329-acb3-6c8e3ad4438e")+		)+		(pin "2"+			(uuid "c434422f-6ec5-47e2-a453-1698a00c86b4")+		)+		(instances+			(project "checks"+				(path "/a0017973-c002-4164-a9dd-9a4bf75c5621"+					(reference "R1")+					(unit 1)+				)+			)+		)+	)+	(wire+		(pts+			(xy 50.8 54.61) (xy 50.8 60.96)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "c008803e-f147-4924-b488-0996690f0f9b")+	)+	(label "LONELY"+		(at 50.8 60.96 0)+		(effects+			(font+				(size 1.27 1.27)+			)+			(justify left bottom)+		)+		(uuid "7974ada9-9537-4f30-9f8b-4142e3a42998")+	)+	(symbol+		(lib_id "Regulator_Linear:AP2112K-3.3")+		(at 101.6 101.6 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "6cb057fb-615b-4a3b-a7dd-d487a6a5d229")+		(property "Reference" "U1"+			(at 104.14 100.33 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "AP2112K-3.3"+			(at 104.14 102.87 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" "Package_TO_SOT_SMD:SOT-23-5"+			(at 101.6 101.6 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" "https://www.diodes.com/assets/Datasheets/AP2112.pdf"+			(at 101.6 101.6 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "600mA low dropout linear regulator, with enable pin, 3.8V-6V input voltage range, 3.3V fixed positive output, SOT-23-5"+			(at 101.6 101.6 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "81983c92-8a31-4f10-ba47-6e8eb06058b6")+		)+		(pin "2"+			(uuid "1dce5da5-a4f1-4d5a-8b9c-3d54e0dd2675")+		)+		(pin "3"+			(uuid "f381ad02-08f7-45e3-8264-42ccc7bfc6bc")+		)+		(pin "4"+			(uuid "ee466b4b-610c-4f61-8bcc-ac418d2193e8")+		)+		(pin "5"+			(uuid "f833b42b-27b0-46af-9da4-e259acec7d76")+		)+		(instances+			(project "checks"+				(path "/a0017973-c002-4164-a9dd-9a4bf75c5621"+					(reference "U1")+					(unit 1)+				)+			)+		)+	)+	(wire+		(pts+			(xy 93.98 99.06) (xy 88.9 99.06)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "4d956c0f-fc0f-47a3-9100-3d8d4d343673")+	)+	(symbol+		(lib_id "power:+5V")+		(at 88.9 99.06 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "a068ff6c-bf21-4ed3-b830-5c2441ea9bac")+		(property "Reference" "#PWR01"+			(at 91.44 97.79 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+				(hide yes)+			)+		)+		(property "Value" "+5V"+			(at 91.44 100.33 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 88.9 99.06 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 88.9 99.06 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Power symbol creates a global label with name \"+5V\""+			(at 88.9 99.06 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "441dfcaa-7f2e-4a42-801b-6ffd03b1b03b")+		)+		(instances+			(project "checks"+				(path "/a0017973-c002-4164-a9dd-9a4bf75c5621"+					(reference "#PWR01")+					(unit 1)+				)+			)+		)+	)+	(wire+		(pts+			(xy 101.6 109.22) (xy 101.6 111.76)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "c7f78d4f-e2c0-4d84-b440-b445c0b29802")+	)+	(symbol+		(lib_id "power:GND")+		(at 101.6 111.76 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "2f249d91-1b09-443e-9251-eb1d8f6b3c85")+		(property "Reference" "#PWR02"+			(at 104.14 110.49 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+				(hide yes)+			)+		)+		(property "Value" "GND"+			(at 104.14 113.03 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 101.6 111.76 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 101.6 111.76 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Power symbol creates a global label with name \"GND\" , ground"+			(at 101.6 111.76 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "f1280c9f-fda3-4c61-b0bb-615b94a87807")+		)+		(instances+			(project "checks"+				(path "/a0017973-c002-4164-a9dd-9a4bf75c5621"+					(reference "#PWR02")+					(unit 1)+				)+			)+		)+	)+	(wire+		(pts+			(xy 109.22 99.06) (xy 114.3 99.06)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "94be8873-d980-4415-a0a7-c73e4c226b73")+	)+	(symbol+		(lib_id "power:+3V3")+		(at 114.3 99.06 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "23704538-5a43-4eb7-9283-cc2791082e56")+		(property "Reference" "#PWR03"+			(at 116.84 97.79 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+				(hide yes)+			)+		)+		(property "Value" "+3V3"+			(at 116.84 100.33 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 114.3 99.06 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 114.3 99.06 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Power symbol creates a global label with name \"+3V3\""+			(at 114.3 99.06 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "5799c5e6-fee0-41c6-91b5-91446bcee528")+		)+		(instances+			(project "checks"+				(path "/a0017973-c002-4164-a9dd-9a4bf75c5621"+					(reference "#PWR03")+					(unit 1)+				)+			)+		)+	)+	(sheet_instances+		(path "/"+			(page "1")+		)+	)+	(embedded_fonts no)+)
rust/crates/kicad-core/tests/fixtures/sch_hier/filter.kicad_schadded+844
@@ -0,0 +1,844 @@+(kicad_sch+	(version 20260306)+	(generator "eeschema")+	(generator_version "10.0")+	(uuid "83a78682-bec0-4054-806b-7fe9a5bf1bc0")+	(paper "A4")+	(title_block+		(title "Filter")+		(date "2026-10-27")+		(rev "A")+	)+	(lib_symbols+		(symbol "Device:LED"+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 1.016)+				(hide yes)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "D"+				(at 0 2.54 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Value" "LED"+				(at 0 -2.54 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Footprint" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Light emitting diode"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Sim.Pins" "1=K 2=A"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "LED diode"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_fp_filters" "LED* LED_SMD:* LED_THT:*"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "LED_0_1"+				(polyline+					(pts+						(xy -3.048 -0.762) (xy -4.572 -2.286) (xy -3.81 -2.286) (xy -4.572 -2.286) (xy -4.572 -1.524)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy -1.778 -0.762) (xy -3.302 -2.286) (xy -2.54 -2.286) (xy -3.302 -2.286) (xy -3.302 -1.524)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy -1.27 0) (xy 1.27 0)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy -1.27 -1.27) (xy -1.27 1.27)+					)+					(stroke+						(width 0.254)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy 1.27 -1.27) (xy 1.27 1.27) (xy -1.27 0) (xy 1.27 -1.27)+					)+					(stroke+						(width 0.254)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "LED_1_1"+				(pin passive line+					(at -3.81 0 0)+					(length 2.54)+					(name "K"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin passive line+					(at 3.81 0 180)+					(length 2.54)+					(name "A"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "2"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+		(symbol "power:GND"+			(power global)+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 0)+				(hide yes)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "#PWR"+				(at 0 -6.35 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Value" "GND"+				(at 0 -3.81 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Footprint" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Power symbol creates a global label with name \"GND\" , ground"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "global power"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "GND_0_1"+				(polyline+					(pts+						(xy 0 0) (xy 0 -1.27) (xy 1.27 -1.27) (xy 0 -2.54) (xy -1.27 -1.27) (xy 0 -1.27)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "GND_1_1"+				(pin power_in line+					(at 0 0 270)+					(length 0)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+		(symbol "Device:C"+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 0.254)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "C"+				(at 0.635 2.54 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+					(justify left)+				)+			)+			(property "Value" "C"+				(at 0.635 -2.54 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+					(justify left)+				)+			)+			(property "Footprint" ""+				(at 0.9652 -3.81 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Unpolarized capacitor"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "cap capacitor"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_fp_filters" "C_*"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "C_0_1"+				(polyline+					(pts+						(xy -2.032 0.762) (xy 2.032 0.762)+					)+					(stroke+						(width 0.508)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy -2.032 -0.762) (xy 2.032 -0.762)+					)+					(stroke+						(width 0.508)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "C_1_1"+				(pin passive line+					(at 0 3.81 270)+					(length 2.794)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin passive line+					(at 0 -3.81 90)+					(length 2.794)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "2"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+	)+	(hierarchical_label "VIN"+		(shape input)+		(at 50.8 50.8 180)+		(fields_autoplaced yes)+		(effects+			(font+				(size 1.27 1.27)+			)+			(justify right)+		)+		(uuid "b718886f-7e8f-4976-89de-7107107d736b")+	)+	(symbol+		(lib_id "Device:LED")+		(at 66.04 50.8 180)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "00b6c8ec-b29d-47e5-9f33-34c66df733f6")+		(property "Reference" "D1"+			(at 68.58 49.53 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "RED"+			(at 68.58 52.07 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 66.04 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 66.04 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Light emitting diode"+			(at 66.04 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Sim.Pins" "1=K 2=A"+			(at 66.04 50.8 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "97344703-57d7-4ce0-b9c2-0a5fe5e1ad75")+		)+		(pin "2"+			(uuid "4d2f05e2-45df-4b24-8041-5ce4364397c4")+		)+		(instances+			(project "hier"+				(path "/4b27afbc-6492-4606-90bb-981fbb98399d/a2400ee4-2dac-4cf4-ad38-44ff51491f3f/00c16012-0527-4161-807f-87cd04c1211c"+					(reference "D1")+					(unit 1)+				)+			)+		)+	)+	(wire+		(pts+			(xy 50.8 50.8) (xy 62.23 50.8)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "acdf2ad0-85a2-4f0c-b08f-845f6db09706")+	)+	(wire+		(pts+			(xy 69.85 50.8) (xy 81.28 50.8)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "125ecf19-73e3-453e-8a13-0ed437fd90f7")+	)+	(wire+		(pts+			(xy 81.28 50.8) (xy 81.28 55.88)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "5b59ddf0-4058-4034-b1e8-32ebf8658125")+	)+	(symbol+		(lib_id "power:GND")+		(at 81.28 55.88 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "1cb507b0-b1b3-49f1-af84-9d3356b974c7")+		(property "Reference" "#PWR08"+			(at 83.82 54.61 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+				(hide yes)+			)+		)+		(property "Value" "GND"+			(at 83.82 57.15 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 81.28 55.88 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 81.28 55.88 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Power symbol creates a global label with name \"GND\" , ground"+			(at 81.28 55.88 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "cb8a0c81-3b7d-4a73-88a0-32533fdcc37f")+		)+		(instances+			(project "hier"+				(path "/4b27afbc-6492-4606-90bb-981fbb98399d/a2400ee4-2dac-4cf4-ad38-44ff51491f3f/00c16012-0527-4161-807f-87cd04c1211c"+					(reference "#PWR08")+					(unit 1)+				)+			)+		)+	)+	(symbol+		(lib_id "Device:C")+		(at 55.88 58.42 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "4ac1628a-f174-4f2b-819f-ad3bcfd74902")+		(property "Reference" "C3"+			(at 58.42 57.15 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "100n"+			(at 58.42 59.69 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 55.88 58.42 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 55.88 58.42 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Unpolarized capacitor"+			(at 55.88 58.42 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "3d32a827-8691-4c2e-8fc7-3123401bb680")+		)+		(pin "2"+			(uuid "4c391637-de1a-4750-bb82-e0b602b28e8b")+		)+		(instances+			(project "hier"+				(path "/4b27afbc-6492-4606-90bb-981fbb98399d/a2400ee4-2dac-4cf4-ad38-44ff51491f3f/00c16012-0527-4161-807f-87cd04c1211c"+					(reference "C3")+					(unit 1)+				)+			)+		)+	)+	(wire+		(pts+			(xy 55.88 54.61) (xy 55.88 50.8)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "683e0f34-824f-46b3-b219-f1d97374b3d0")+	)+	(junction+		(at 55.88 50.8)+		(diameter 0)+		(color 0 0 0 0)+		(uuid "a04bf76a-6379-4bd9-b3e1-17d60336e244")+	)+	(wire+		(pts+			(xy 55.88 62.23) (xy 55.88 66.04)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "ec8ed46b-aad1-404d-be81-295da463818c")+	)+	(symbol+		(lib_id "power:GND")+		(at 55.88 66.04 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "6aac7ea5-9b95-49a4-8578-564d1c896cc1")+		(property "Reference" "#PWR09"+			(at 58.42 64.77 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+				(hide yes)+			)+		)+		(property "Value" "GND"+			(at 58.42 67.31 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 55.88 66.04 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 55.88 66.04 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Power symbol creates a global label with name \"GND\" , ground"+			(at 55.88 66.04 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "d3232e42-512d-4a17-b499-64f885918870")+		)+		(instances+			(project "hier"+				(path "/4b27afbc-6492-4606-90bb-981fbb98399d/a2400ee4-2dac-4cf4-ad38-44ff51491f3f/00c16012-0527-4161-807f-87cd04c1211c"+					(reference "#PWR09")+					(unit 1)+				)+			)+		)+	)+	(sheet_instances+		(path "/"+			(page "1")+		)+	)+	(embedded_fonts no)+)
rust/crates/kicad-core/tests/fixtures/sch_hier/hier.kicad_proadded+13
@@ -0,0 +1,13 @@+{+  "boards": [],+  "libraries": {+    "pinned_footprint_libs": [],+    "pinned_symbol_libs": []+  },+  "meta": {+    "filename": "hier.kicad_pro",+    "version": 3+  },+  "sheets": [],+  "text_variables": {}+}
rust/crates/kicad-core/tests/fixtures/sch_hier/hier.kicad_schadded+945
@@ -0,0 +1,945 @@+(kicad_sch+	(version 20260306)+	(generator "eeschema")+	(generator_version "10.0")+	(uuid "4b27afbc-6492-4606-90bb-981fbb98399d")+	(paper "A4")+	(title_block+		(title "Hierarchy fixture")+		(date "2026-10-27")+		(rev "A")+	)+	(lib_symbols+		(symbol "Connector:Conn_01x02_Pin"+			(pin_names+				(offset 1.016)+				(hide yes)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "J"+				(at 0 2.54 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Value" "Conn_01x02_Pin"+				(at 0 -5.08 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Footprint" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Generic connector, single row, 01x02, script generated"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_locked" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "connector"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_fp_filters" "Connector*:*_1x??_*"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "Conn_01x02_Pin_1_1"+				(rectangle+					(start 0.8636 0.127)+					(end 0 -0.127)+					(stroke+						(width 0.1524)+						(type default)+					)+					(fill+						(type outline)+					)+				)+				(rectangle+					(start 0.8636 -2.413)+					(end 0 -2.667)+					(stroke+						(width 0.1524)+						(type default)+					)+					(fill+						(type outline)+					)+				)+				(polyline+					(pts+						(xy 1.27 0) (xy 0.8636 0)+					)+					(stroke+						(width 0.1524)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy 1.27 -2.54) (xy 0.8636 -2.54)+					)+					(stroke+						(width 0.1524)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(pin passive line+					(at 5.08 0 180)+					(length 3.81)+					(name "Pin_1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+				(pin passive line+					(at 5.08 -2.54 180)+					(length 3.81)+					(name "Pin_2"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "2"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+		(symbol "power:+5V"+			(power global)+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 0)+				(hide yes)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "#PWR"+				(at 0 -3.81 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Value" "+5V"+				(at 0 3.556 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Footprint" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Power symbol creates a global label with name \"+5V\""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "global power"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "+5V_0_1"+				(polyline+					(pts+						(xy -0.762 1.27) (xy 0 2.54)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy 0 2.54) (xy 0.762 1.27)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+				(polyline+					(pts+						(xy 0 0) (xy 0 2.54)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "+5V_1_1"+				(pin power_in line+					(at 0 0 90)+					(length 0)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+		(symbol "power:GND"+			(power global)+			(pin_numbers+				(hide yes)+			)+			(pin_names+				(offset 0)+				(hide yes)+			)+			(exclude_from_sim no)+			(in_bom yes)+			(on_board yes)+			(in_pos_files yes)+			(duplicate_pin_numbers_are_jumpers no)+			(property "Reference" "#PWR"+				(at 0 -6.35 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Value" "GND"+				(at 0 -3.81 0)+				(show_name no)+				(do_not_autoplace no)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Footprint" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Datasheet" ""+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "Description" "Power symbol creates a global label with name \"GND\" , ground"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(property "ki_keywords" "global power"+				(at 0 0 0)+				(show_name no)+				(do_not_autoplace no)+				(hide yes)+				(effects+					(font+						(size 1.27 1.27)+					)+				)+			)+			(symbol "GND_0_1"+				(polyline+					(pts+						(xy 0 0) (xy 0 -1.27) (xy 1.27 -1.27) (xy 0 -2.54) (xy -1.27 -1.27) (xy 0 -1.27)+					)+					(stroke+						(width 0)+						(type default)+					)+					(fill+						(type none)+					)+				)+			)+			(symbol "GND_1_1"+				(pin power_in line+					(at 0 0 270)+					(length 0)+					(name ""+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+					(number "1"+						(effects+							(font+								(size 1.27 1.27)+							)+						)+					)+				)+			)+			(embedded_fonts no)+		)+	)+	(symbol+		(lib_id "Connector:Conn_01x02_Pin")+		(at 30.48 60.96 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "348159c6-badc-430b-ad76-516cca597da1")+		(property "Reference" "J1"+			(at 33.02 59.69 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "5V_IN"+			(at 33.02 62.23 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 30.48 60.96 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 30.48 60.96 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Generic connector, single row, 01x02, script generated"+			(at 30.48 60.96 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "29f40c04-99f5-469e-93c4-c25a066751ed")+		)+		(pin "2"+			(uuid "e64edbb9-55cb-498e-b3e1-2c63f05cd421")+		)+		(instances+			(project "hier"+				(path "/4b27afbc-6492-4606-90bb-981fbb98399d"+					(reference "J1")+					(unit 1)+				)+			)+		)+	)+	(wire+		(pts+			(xy 35.56 60.96) (xy 45.72 60.96)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "bb9debcc-8fbf-4ec5-ab03-c5ad68679f0b")+	)+	(symbol+		(lib_id "power:+5V")+		(at 45.72 60.96 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "2fb84350-f90e-41cc-a34f-b8c1f785f8fd")+		(property "Reference" "#PWR01"+			(at 48.26 59.69 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+				(hide yes)+			)+		)+		(property "Value" "+5V"+			(at 48.26 62.23 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 45.72 60.96 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 45.72 60.96 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Power symbol creates a global label with name \"+5V\""+			(at 45.72 60.96 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "1812fc49-575a-42d8-a963-7a7da61a03a3")+		)+		(instances+			(project "hier"+				(path "/4b27afbc-6492-4606-90bb-981fbb98399d"+					(reference "#PWR01")+					(unit 1)+				)+			)+		)+	)+	(wire+		(pts+			(xy 35.56 63.5) (xy 45.72 63.5)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "b78f98b8-d754-4fcc-8440-368867529f85")+	)+	(wire+		(pts+			(xy 45.72 63.5) (xy 45.72 68.58)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "bcb494ed-cd10-43f8-9664-c3b6abdb3055")+	)+	(symbol+		(lib_id "power:GND")+		(at 45.72 68.58 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "50c3470a-8e9d-4d6f-973d-7a5a58c56dc4")+		(property "Reference" "#PWR02"+			(at 48.26 67.31 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+				(hide yes)+			)+		)+		(property "Value" "GND"+			(at 48.26 69.85 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 45.72 68.58 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 45.72 68.58 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Power symbol creates a global label with name \"GND\" , ground"+			(at 45.72 68.58 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "394f81e3-a477-4d84-845a-ab4a96d57b21")+		)+		(instances+			(project "hier"+				(path "/4b27afbc-6492-4606-90bb-981fbb98399d"+					(reference "#PWR02")+					(unit 1)+				)+			)+		)+	)+	(sheet+		(at 63.5 45.72)+		(size 30.48 20.32)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(stroke+			(width 0.1524)+			(type solid)+		)+		(fill+			(color 0 0 0 0.0000)+		)+		(uuid "a2400ee4-2dac-4cf4-ad38-44ff51491f3f")+		(property "Sheetname" "Power"+			(at 63.5 45.0084 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left bottom)+			)+		)+		(property "Sheetfile" "power.kicad_sch"+			(at 63.5 66.6246 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left top)+			)+		)+		(pin "VOUT" output+			(at 93.98 50.8 0)+			(uuid "49be153b-a5c9-4abe-9421-021a09a30495")+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify right)+			)+		)+		(instances+			(project "hier"+				(path "/4b27afbc-6492-4606-90bb-981fbb98399d"+					(page "2")+				)+			)+		)+	)+	(sheet+		(at 127 45.72)+		(size 30.48 20.32)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(stroke+			(width 0.1524)+			(type solid)+		)+		(fill+			(color 0 0 0 0.0000)+		)+		(uuid "441f215d-5859-45bf-b43f-cf9fdb3aed52")+		(property "Sheetname" "MCU"+			(at 127 45.0084 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left bottom)+			)+		)+		(property "Sheetfile" "mcu.kicad_sch"+			(at 127 66.6246 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left top)+			)+		)+		(pin "VDD" input+			(at 127 50.8 180)+			(uuid "95efef4d-3209-461a-9a16-9de12bf34800")+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(pin "SDA" bidirectional+			(at 127 55.88 180)+			(uuid "b3c1ecbe-db75-4c14-9e74-c2120803d42a")+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(pin "SCL" input+			(at 127 60.96 180)+			(uuid "77837008-6987-482c-b942-fd4846131294")+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(instances+			(project "hier"+				(path "/4b27afbc-6492-4606-90bb-981fbb98399d"+					(page "4")+				)+			)+		)+	)+	(wire+		(pts+			(xy 93.98 50.8) (xy 127 50.8)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "a20aea6b-bc0d-48ec-b853-b5916b9e75d6")+	)+	(symbol+		(lib_id "Connector:Conn_01x02_Pin")+		(at 101.6 58.42 0)+		(unit 1)+		(exclude_from_sim no)+		(in_bom yes)+		(on_board yes)+		(dnp no)+		(fields_autoplaced yes)+		(uuid "5f4c768a-3cc5-4d9d-817e-98267dcbaf8f")+		(property "Reference" "J2"+			(at 104.14 57.15 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Value" "I2C"+			(at 104.14 59.69 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(justify left)+			)+		)+		(property "Footprint" ""+			(at 101.6 58.42 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Datasheet" ""+			(at 101.6 58.42 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(property "Description" "Generic connector, single row, 01x02, script generated"+			(at 101.6 58.42 0)+			(effects+				(font+					(size 1.27 1.27)+				)+				(hide yes)+			)+		)+		(pin "1"+			(uuid "d01f0378-c4da-4d80-a855-9a2559fefb86")+		)+		(pin "2"+			(uuid "232d90f8-6e5f-43ea-8fce-65e266bc9908")+		)+		(instances+			(project "hier"+				(path "/4b27afbc-6492-4606-90bb-981fbb98399d"+					(reference "J2")+					(unit 1)+				)+			)+		)+	)+	(wire+		(pts+			(xy 106.68 58.42) (xy 116.84 58.42)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "f7aa12ff-138a-4793-951a-b185f58a1627")+	)+	(wire+		(pts+			(xy 116.84 58.42) (xy 116.84 55.88)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "67093a45-f804-4480-9b13-96b7483e95c5")+	)+	(wire+		(pts+			(xy 116.84 55.88) (xy 127 55.88)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "57527b6f-31bc-40bd-b3e2-c97555f57bd9")+	)+	(wire+		(pts+			(xy 106.68 60.96) (xy 119.38 60.96)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "004c4b02-a7c7-4038-aa6c-eb08c066197c")+	)+	(wire+		(pts+			(xy 119.38 60.96) (xy 127 60.96)+		)+		(stroke+			(width 0)+			(type default)+		)+		(uuid "4c9a494d-1769-4502-83fe-27cd3ea134a9")+	)+	(sheet_instances+		(path "/"+			(page "1")+		)+	)+	(embedded_fonts no)+)