app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← 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).
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+945rust/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, ©_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", ®ulator(), 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", ®ulator(), false).unwrap_err().contains("overwrite"));+ let (lib2, replaced) = add_symbol(&lib, "LM1117", ®ulator(), 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)+)