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.
master
John Lauer
1.0.61: Every Konnect v0.13.0 capability, 211 verbs (was 133; docs/konnect-gap.md maps all 228 Konnect tools). Schemati…
baad156
14d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025
//! 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");
}
}