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
Colby Knox
Merge PR #399: 1.0.100: update_pcb sets every reference (closes #138); the verbs ruled on standard #29
85f32a2
8d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
//! Update PCB from Schematic, on the file: compare the schematic's netlist with the board
//! (missing, extra and changed footprints, changed values, pad nets) and, on a CLOSED
//! board, apply the plan: new footprints from the libraries in a row below the board,
//! values and links updated, pad nets assigned, extras marked (deleted only on request).
//!
//! Footprints are matched by reference, the way KiCad's "Re-link footprints to schematic
//! symbols based on their reference designators" option does; the symbol uuid becomes the
//! footprint's `(path ...)` so KiCad's own F8 matches them by uuid afterwards.
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use serde_json::{json, Value};
use crate::libraries::{self, LibCtx, LibTable};
use crate::netlist::{Component, Netlist};
use crate::pcb::{self, Board, NetFormat, RoutingError};
use crate::placement;
use crate::route_tools::top_level_spans;
use crate::schematic::new_uuid;
type R<T> = Result<T, RoutingError>;
/// The marker property an extra footprint gets on apply.
pub const EXTRA_MARK: &str = "Adom_NotInSchematic";
fn q(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
}
/// `(path ...)` for a netlist component: its sheet path plus its symbol uuid.
pub fn symbol_path(c: &Component) -> Option<String> {
if c.tstamp.is_empty() {
return None;
}
let mut sheet = if c.sheet_tstamps.is_empty() { "/".to_string() } else { c.sheet_tstamps.clone() };
if !sheet.ends_with('/') {
sheet.push('/');
}
Some(format!("{sheet}{}", c.tstamp))
}
// ---------------------------------------------------------------------------
// Compare
// ---------------------------------------------------------------------------
fn footprint_spans(text: &str) -> Vec<(usize, usize)> {
top_level_spans(text).into_iter().filter(|(s, _)| text[*s..].starts_with("(footprint")).collect()
}
/// The span of the board footprint with this reference.
pub fn footprint_span(text: &str, reference: &str) -> Option<(usize, usize)> {
let needle = format!("(property \"Reference\" {}", q(reference));
let legacy = format!("(fp_text reference {}", q(reference));
footprint_spans(text).into_iter().find(|(s, e)| text[*s..*e].contains(&needle) || text[*s..*e].contains(&legacy))
}
fn board_only(text: &str, reference: &str) -> bool {
footprint_span(text, reference).map(|(s, e)| text[s..e].lines().any(|l| l.trim_start().starts_with("(attr") && l.contains("board_only"))).unwrap_or(false)
}
/// The difference between the netlist and the board, as the plan reports it.
pub fn compare(board: &Board, text: &str, nl: &Netlist) -> Value {
let on_board: HashMap<&str, &pcb::Footprint> = board.footprints.iter().map(|f| (f.reference.as_str(), f)).collect();
let mut missing = Vec::new();
let mut no_footprint = Vec::new();
let mut values = Vec::new();
let mut fps = Vec::new();
let mut links = Vec::new();
for c in &nl.components {
match on_board.get(c.reference.as_str()) {
None if c.footprint.is_empty() => no_footprint.push(json!(c.reference)),
None => missing.push(json!({"ref": c.reference, "value": c.value, "footprint": c.footprint})),
Some(f) => {
if f.value != c.value {
values.push(json!({"ref": c.reference, "from": f.value, "to": c.value}));
}
if !c.footprint.is_empty() && f.footprint != c.footprint {
fps.push(json!({"ref": c.reference, "from": f.footprint, "to": c.footprint}));
}
if let Some(p) = symbol_path(c) {
if f.path.as_deref() != Some(p.as_str()) {
links.push(json!({"ref": c.reference, "from": f.path, "to": p}));
}
}
}
}
}
let in_netlist: Vec<&str> = nl.components.iter().map(|c| c.reference.as_str()).collect();
let mut extra = Vec::new();
let mut kept = Vec::new();
for f in &board.footprints {
if in_netlist.contains(&f.reference.as_str()) {
continue;
}
if board_only(text, &f.reference) {
kept.push(json!(f.reference));
} else {
extra.push(json!({"ref": f.reference, "value": f.value, "footprint": f.footprint}));
}
}
let pad_nets = pad_net_changes(board, nl);
let total = missing.len() + extra.len() + values.len() + fps.len() + pad_nets.len() + links.len();
json!({
"inSync": total == 0, "changeCount": total,
"missingFootprints": missing, "extraFootprints": extra, "boardOnlyFootprints": kept,
"changedValues": values, "changedFootprints": fps, "padNetChanges": pad_nets, "relinks": links,
"componentsWithoutFootprint": no_footprint,
"counts": {"schematicComponents": nl.components.len(), "boardFootprints": board.footprints.len(), "schematicNets": nl.nets.len()},
})
}
fn expected_nets(nl: &Netlist) -> HashMap<(String, String), String> {
let mut m = HashMap::new();
for n in &nl.nets {
for node in &n.nodes {
m.insert((node.reference.clone(), node.pin.clone()), n.name.clone());
}
}
m
}
/// Every pad of a footprint the netlist knows whose net differs from the netlist's.
pub fn pad_net_changes(board: &Board, nl: &Netlist) -> Vec<Value> {
let want = expected_nets(nl);
let known: Vec<&str> = nl.components.iter().map(|c| c.reference.as_str()).collect();
let mut out = Vec::new();
for f in &board.footprints {
if !known.contains(&f.reference.as_str()) {
continue;
}
let mut seen = Vec::new();
for p in &f.pads {
if p.number.is_empty() || seen.contains(&p.number) {
continue;
}
seen.push(p.number.clone());
let have = p.net_name.clone().unwrap_or_default();
let to = want.get(&(f.reference.clone(), p.number.clone())).cloned().unwrap_or_default();
// KiCad names an unconnected pin's net "unconnected-(...)": a pad with no net is the same thing.
// The board keeps a net name escaped (a label VPP/MCLR is /VPP{slash}MCLR), the netlist does not.
let have_plain = unescape_net(&have);
let same = have_plain == to || (have.is_empty() && to.starts_with("unconnected-")) || (to.is_empty() && have.starts_with("unconnected-"));
if !same {
out.push(json!({"pad": format!("{}.{}", f.reference, p.number), "from": have, "to": to}));
}
}
}
out
}
/// A net name as the board file escapes it, read back plain: KiCad writes characters that mean
/// something in a net path or the file format as {slash}, {backslash}, {lt}, {gt}, {colon},
/// {dblquote}, {space}, {tab}, {return}, {brace}.
pub fn unescape_net(name: &str) -> String {
if !name.contains('{') {
return name.to_string();
}
[("{slash}", "/"), ("{backslash}", "\\"), ("{lt}", "<"), ("{gt}", ">"), ("{colon}", ":"), ("{dblquote}", "\""), ("{space}", " "), ("{tab}", "\t"), ("{return}", "\n"), ("{brace}", "{")]
.iter().fold(name.to_string(), |s, (from, to)| s.replace(from, to))
}
// ---------------------------------------------------------------------------
// Text edits inside a footprint block
// ---------------------------------------------------------------------------
/// Spans of the direct children of the list at `span`.
pub fn child_spans(text: &str, span: (usize, usize)) -> Vec<(usize, usize)> {
let inner = &text[span.0..span.1];
top_level_spans(inner).into_iter().map(|(a, b)| (a + span.0, b + span.0)).collect()
}
/// One s-expression token at `i` (quoted, escapes respected, or bare): its end.
fn token_end(block: &str, i: usize) -> usize {
let b = block.as_bytes();
if b.get(i) == Some(&b'"') {
let mut j = i + 1;
while j < b.len() {
match b[j] {
b'\\' => j += 2,
b'"' => return j + 1,
_ => j += 1,
}
}
return b.len();
}
let mut j = i;
while j < b.len() && !b[j].is_ascii_whitespace() && b[j] != b'(' && b[j] != b')' {
j += 1;
}
j
}
fn skip_space(block: &str, mut i: usize) -> usize {
let b = block.as_bytes();
while i < b.len() && b[i].is_ascii_whitespace() {
i += 1;
}
i
}
/// Where `open` (a list head such as `(property`) starts a list, however it is spaced: the positions after it.
fn list_heads<'a>(block: &'a str, open: &'a str) -> impl Iterator<Item = (usize, usize)> + 'a {
block.match_indices(open).filter_map(move |(i, _)| {
let after = i + open.len();
block.as_bytes().get(after).filter(|c| c.is_ascii_whitespace()).map(|_| (i, after))
})
}
/// A footprint's Reference or Value, whichever way its library wrote it (wiki #138: library files spaced or broken
/// over lines differently from KiCad's own, and KiCad 5/6's `fp_text`, kept their REF**). A footprint with neither
/// gets the property. Rewritten as KiCad writes it, because `board_only` and other readers look for that spelling.
fn set_property(block: &str, key: &str, value: &str) -> String {
let unquote = |t: &str| t.trim_matches('"').to_string();
for (start, after) in list_heads(block, "(property") {
let k0 = skip_space(block, after);
let k1 = token_end(block, k0);
if unquote(&block[k0..k1]) != key {
continue;
}
let v0 = skip_space(block, k1);
let v1 = token_end(block, v0);
let mut out = block.to_string();
out.replace_range(start..v1, &format!("(property {} {}", q(key), q(value)));
return out;
}
let legacy = key.to_ascii_lowercase();
for (_, after) in list_heads(block, "(fp_text") {
let k0 = skip_space(block, after);
let k1 = token_end(block, k0);
if block[k0..k1] != legacy {
continue;
}
let v0 = skip_space(block, k1);
let v1 = token_end(block, v0);
let mut out = block.to_string();
out.replace_range(v0..v1, &q(value));
return out;
}
let layer = if key == "Reference" { "F.SilkS" } else { "F.Fab" };
let prop = format!("(property {} {} (at 0 0 0) (layer {}) (uuid {}) (effects (font (size 1 1) (thickness 0.15))))\n\t\t", q(key), q(value), q(layer), q(&new_uuid()));
let mut out = block.to_string();
let at = list_heads(block, "(fp_").chain(list_heads(block, "(pad")).map(|(i, _)| i).min().or_else(|| block.rfind(')')).unwrap_or(block.len());
out.insert_str(at, &prop);
out
}
fn net_clause(fmt: NetFormat, number: Option<i64>, name: &str) -> String {
match fmt {
NetFormat::Name => format!("(net {})", q(name)),
NetFormat::Number => format!("(net {} {})", number.unwrap_or(0), q(name)),
}
}
/// Set (or clear, with `None`) the net of every pad numbered `pad` in the footprint block.
pub fn set_pad_net(block: &str, pad: &str, net: Option<&str>, fmt: NetFormat, numbers: &BTreeMap<String, i64>) -> String {
let mut out = block.to_string();
let head = format!("(pad {} ", q(pad));
// walk from the end so earlier spans stay valid
let pads: Vec<(usize, usize)> = child_spans(&out, (0, out.len())).into_iter().filter(|(s, _)| out[*s..].starts_with(&head)).collect();
for (s, e) in pads.into_iter().rev() {
let pad_text = out[s..e].to_string();
let children = child_spans(&pad_text, (0, pad_text.len()));
let existing = children.iter().find(|(a, _)| pad_text[*a..].starts_with("(net "));
let clause = net.map(|n| net_clause(fmt, numbers.get(n).copied(), n));
let new_pad = match (existing, clause) {
(Some((a, b)), Some(c)) => format!("{}{}{}", &pad_text[..*a], c, &pad_text[*b..]),
(Some((a, b)), None) => {
let mut a2 = *a;
while a2 > 0 && matches!(pad_text.as_bytes()[a2 - 1], b' ' | b'\t') {
a2 -= 1;
}
if a2 > 0 && pad_text.as_bytes()[a2 - 1] == b'\n' {
a2 -= 1;
}
format!("{}{}", &pad_text[..a2], &pad_text[*b..])
}
(None, Some(c)) => {
// before the uuid when there is one, else before the closing paren
match children.iter().find(|(a, _)| pad_text[*a..].starts_with("(uuid")) {
Some((a, _)) => format!("{}{}\n\t\t\t{}", &pad_text[..*a], c, &pad_text[*a..]),
None => format!("{} {})", pad_text[..pad_text.len() - 1].trim_end(), c),
}
}
(None, None) => pad_text.clone(),
};
out.replace_range(s..e, &new_pad);
}
out
}
fn add_angle(block: &str, rot: f64) -> String {
if rot == 0.0 {
return block.to_string();
}
let mut out = block.to_string();
let targets: Vec<(usize, usize)> = child_spans(&out, (0, out.len())).into_iter().filter(|(s, _)| ["(pad", "(property", "(fp_text"].iter().any(|h| out[*s..].starts_with(h) && out[*s + h.len()..].starts_with(|c: char| c.is_whitespace()))).collect();
for (s, e) in targets.into_iter().rev() {
let item = out[s..e].to_string();
let Some(at) = child_spans(&item, (0, item.len())).into_iter().find(|(a, _)| item[*a..].starts_with("(at ")) else { continue };
let inner = &item[at.0 + 4..at.1 - 1];
let nums: Vec<&str> = inner.split_whitespace().collect();
if nums.len() < 2 {
continue;
}
let a = nums.get(2).and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0) + rot;
let a = ((a % 360.0) + 360.0) % 360.0;
let rest = if nums.len() > 3 { format!(" {}", nums[3..].join(" ")) } else { String::new() };
let new_at = format!("(at {} {} {}{rest})", nums[0], nums[1], pcb::num(a));
let mut it = item.clone();
it.replace_range(at.0..at.1, &new_at);
out.replace_range(s..e, &it);
}
out
}
/// A library `.kicad_mod` turned into a board footprint block: lib id, uuid, pose,
/// reference, value, schematic link, fresh child uuids, pad nets.
#[allow(clippy::too_many_arguments)]
pub fn instantiate(module: &str, lib_id: &str, reference: &str, value: &str, at: (f64, f64), rot: f64, layer_side: &str, uuid: &str, path: Option<&str>, pad_net: &dyn Fn(&str) -> Option<String>, fmt: NetFormat, numbers: &BTreeMap<String, i64>) -> R<String> {
let t = module.trim();
if !(t.starts_with("(footprint") || t.starts_with("(module")) {
return Err(RoutingError::new("library_unreadable", format!("{lib_id}: not a .kicad_mod footprint")));
}
if layer_side != "F.Cu" {
return Err(RoutingError::new("back_side_not_supported", "footprints are added on the front; flip them in KiCad afterwards"));
}
let spans = top_level_spans(t);
let mut body = String::new();
for (s, e) in &spans {
let c = &t[*s..*e];
if ["(version", "(generator", "(generator_version", "(uuid", "(at ", "(path", "(sheetname", "(sheetfile", "(tedit"].iter().any(|h| c.starts_with(h)) {
continue;
}
body.push_str("\n\t\t");
body.push_str(c);
}
let head_name = format!("(footprint {}", q(lib_id));
let mut block = format!("{head_name}{body}\n\t)");
// pose and identity right after the layer
let ident = format!("\n\t\t(uuid {})\n\t\t(at {} {}{}){}", q(uuid), pcb::num(at.0), pcb::num(at.1), if rot != 0.0 { format!(" {}", pcb::num(rot)) } else { String::new() }, path.map(|p| format!("\n\t\t(path {})", q(p))).unwrap_or_default());
match block.find("(layer ") {
Some(i) => {
let j = block[i..].find(')').map(|j| i + j + 1).unwrap_or(i);
block.insert_str(j, &ident);
}
None => {
let j = head_name.len();
block.insert_str(j, &format!("\n\t\t(layer \"F.Cu\"){ident}"));
}
}
// fresh uuids for every child
let mut fresh = String::with_capacity(block.len());
let mut rest = block.as_str();
let mut first = true;
while let Some(i) = rest.find("(uuid \"") {
fresh.push_str(&rest[..i + 7]);
let after = &rest[i + 7..];
let j = after.find('"').unwrap_or(0);
if first {
fresh.push_str(&after[..j]);
first = false;
} else {
fresh.push_str(&new_uuid());
}
rest = &after[j..];
}
fresh.push_str(rest);
let mut block = set_property(&fresh, "Reference", reference);
block = set_property(&block, "Value", value);
block = add_angle(&block, rot);
let mut numbers_seen = Vec::new();
let parsed = pcb::parse_pcb_text(&format!("(kicad_pcb\n\t{block}\n)"), "lib").map_err(|e| RoutingError::new("library_unparsable", format!("{lib_id}: {e}")))?;
if let Some(fp) = parsed.footprints.first() {
// Wiki #138: never hand back a part that still answers to its library's placeholder.
if fp.reference != reference {
return Err(RoutingError::new("reference_not_set", format!("{lib_id}: the footprint reads as {:?} after setting its reference to {reference}", fp.reference)));
}
for p in &fp.pads {
if p.number.is_empty() || numbers_seen.contains(&p.number) {
continue;
}
numbers_seen.push(p.number.clone());
block = set_pad_net(&block, &p.number, pad_net(&p.number).as_deref(), fmt, numbers);
}
}
Ok(format!("\t{block}"))
}
// ---------------------------------------------------------------------------
// Library lookup
// ---------------------------------------------------------------------------
/// The `.kicad_mod` for a `Lib:Name` id: the project's fp-lib-table first, then the global
/// one, `${KIPRJMOD}` resolved to the board's folder.
pub fn resolve_footprint(lib_id: &str, board_dir: &Path, ctx: &LibCtx) -> Result<PathBuf, String> {
let (nick, name) = lib_id.split_once(':').ok_or_else(|| format!("{lib_id} is not Library:Footprint"))?;
let mut tables = vec![board_dir.join("fp-lib-table")];
if let Some(g) = ctx.fp_lib_table() {
tables.push(g);
}
let prj = board_dir.to_string_lossy().replace('\\', "/");
for t in tables.iter().filter(|t| t.is_file()) {
let Ok(table) = LibTable::parse_file(t) else { continue };
let Some(e) = table.get(nick) else { continue };
if e.disabled {
continue;
}
let uri = e.uri.replace("${KIPRJMOD}", &prj);
let Some(dir) = libraries::expand_kicad_uri(&uri, ctx) else { return Err(format!("{nick}: cannot expand {}", e.uri)) };
let f = Path::new(&dir).join(format!("{name}.kicad_mod"));
return if f.is_file() { Ok(f) } else { Err(format!("{name}.kicad_mod is not in {dir}")) };
}
Err(format!("no library named {nick} in the project or global fp-lib-table"))
}
// ---------------------------------------------------------------------------
// Apply
// ---------------------------------------------------------------------------
pub struct ApplyOpts {
pub delete_extra: bool,
pub update_footprints: bool,
}
/// The board text with the plan applied, and what was done. `module` resolves a lib id
/// to its `.kicad_mod` text.
pub fn apply(text: &str, board: &Board, nl: &Netlist, opts: &ApplyOpts, module: &dyn Fn(&str) -> Result<String, String>) -> R<(String, Value)> {
let fmt = board.net_format;
let want = expected_nets(nl);
let mut out = text.to_string();
// nets a numbered board must declare
let mut numbers: BTreeMap<String, i64> = board.nets.iter().filter_map(|n| n.number.map(|k| (n.name.clone(), k))).collect();
let mut declared = Vec::new();
if fmt == NetFormat::Number {
let mut next = numbers.values().max().copied().unwrap_or(0) + 1;
let mut decl = String::new();
for n in &nl.nets {
if !numbers.contains_key(&n.name) {
numbers.insert(n.name.clone(), next);
decl.push_str(&format!("\n\t(net {next} {})", q(&n.name)));
declared.push(n.name.clone());
next += 1;
}
}
if !decl.is_empty() {
let last = top_level_spans(&out).into_iter().filter(|(s, _)| out[*s..].starts_with("(net ")).last();
let at = last.map(|(_, e)| e).ok_or_else(|| RoutingError::new("board_unparsable", "a numbered-net board without a net table"))?;
out.insert_str(at, &decl);
}
}
let mut done = json!({"valuesUpdated": [], "padNetsAssigned": [], "footprintsAdded": [], "footprintsReplaced": [], "extrasMarked": [], "extrasDeleted": [], "relinked": [], "notApplied": [], "netsDeclared": declared});
let push = |d: &mut Value, k: &str, v: Value| d[k].as_array_mut().unwrap().push(v);
let comps: HashMap<&str, &Component> = nl.components.iter().map(|c| (c.reference.as_str(), c)).collect();
for f in &board.footprints {
let Some(c) = comps.get(f.reference.as_str()) else { continue };
let Some(span) = footprint_span(&out, &f.reference) else { continue };
let mut block = out[span.0..span.1].to_string();
let replace = opts.update_footprints && !c.footprint.is_empty() && f.footprint != c.footprint;
if replace {
let pos = f.position.clone().unwrap_or(pcb::Position { x: 0.0, y: 0.0, rotation: None });
let side = f.layer.clone().unwrap_or_else(|| "F.Cu".into());
let made = module(&c.footprint).map_err(|e| RoutingError::new("library_missing", e)).and_then(|m| {
let net = |pad: &str| want.get(&(c.reference.clone(), pad.to_string())).cloned();
instantiate(&m, &c.footprint, &c.reference, &c.value, (pos.x, pos.y), pos.rotation.unwrap_or(0.0), &side, f.uuid.as_deref().unwrap_or(&new_uuid()), symbol_path(c).as_deref().or(f.path.as_deref()), &net, fmt, &numbers)
});
match made {
Ok(b) => {
out.replace_range(span.0..span.1, b.trim_start());
push(&mut done, "footprintsReplaced", json!({"ref": c.reference, "from": f.footprint, "to": c.footprint}));
continue;
}
Err(e) => push(&mut done, "notApplied", json!({"ref": c.reference, "change": "footprint", "to": c.footprint, "reason": e.message})),
}
}
if f.value != c.value {
block = set_property(&block, "Value", &c.value);
push(&mut done, "valuesUpdated", json!({"ref": c.reference, "from": f.value, "to": c.value}));
}
if let Some(p) = symbol_path(c) {
if f.path.as_deref() != Some(p.as_str()) {
match child_spans(&block, (0, block.len())).into_iter().find(|(a, _)| block[*a..].starts_with("(path ")) {
Some((a, b)) => block.replace_range(a..b, &format!("(path {})", q(&p))),
None => {
if let Some(i) = block.find("(at ") {
let j = block[i..].find(')').map(|j| i + j + 1).unwrap_or(i);
block.insert_str(j, &format!("\n\t\t(path {})", q(&p)));
}
}
}
push(&mut done, "relinked", json!({"ref": c.reference, "path": p}));
}
}
let mut seen = Vec::new();
for p in &f.pads {
if p.number.is_empty() || seen.contains(&p.number) {
continue;
}
seen.push(p.number.clone());
let have = p.net_name.clone().unwrap_or_default();
let to = want.get(&(f.reference.clone(), p.number.clone())).cloned().unwrap_or_default();
if have != to && !(have.is_empty() && to.starts_with("unconnected-")) {
block = set_pad_net(&block, &p.number, if to.is_empty() { None } else { Some(&to) }, fmt, &numbers);
push(&mut done, "padNetsAssigned", json!({"pad": format!("{}.{}", f.reference, p.number), "from": have, "to": to}));
}
}
out.replace_range(span.0..span.1, &block);
}
// extras
let known: Vec<&str> = nl.components.iter().map(|c| c.reference.as_str()).collect();
for f in &board.footprints {
if known.contains(&f.reference.as_str()) || board_only(&out, &f.reference) {
continue;
}
let Some(span) = footprint_span(&out, &f.reference) else { continue };
if opts.delete_extra {
let mut s = span.0;
while s > 0 && matches!(out.as_bytes()[s - 1], b' ' | b'\t') {
s -= 1;
}
if s > 0 && out.as_bytes()[s - 1] == b'\n' {
s -= 1;
}
out.replace_range(s..span.1, "");
push(&mut done, "extrasDeleted", json!(f.reference));
} else if !out[span.0..span.1].contains(EXTRA_MARK) {
let mark = format!("\n\t\t(property {} \"yes\"\n\t\t\t(at 0 0 0)\n\t\t\t(layer \"F.Fab\")\n\t\t\t(hide yes)\n\t\t\t(uuid {})\n\t\t\t(effects\n\t\t\t\t(font\n\t\t\t\t\t(size 1 1)\n\t\t\t\t\t(thickness 0.15)\n\t\t\t\t)\n\t\t\t)\n\t\t)", q(EXTRA_MARK), q(&new_uuid()));
let block = &out[span.0..span.1];
let at = block.find("(property ").map(|i| span.0 + i).unwrap_or(span.1 - 1);
// after the last property
let kids = child_spans(&out, span);
let after = kids.iter().filter(|(a, _)| out[*a..].starts_with("(property ")).last().map(|(_, b)| *b).unwrap_or(at);
out.insert_str(after, &mark);
push(&mut done, "extrasMarked", json!(f.reference));
}
}
// missing footprints: a row below the outline
let outline = placement::outline(board);
let (mut x, y0) = outline.map(|o| (o.min_x, o.max_y + 5.0)).unwrap_or((0.0, 0.0));
let mut added_blocks = String::new();
for c in &nl.components {
if board.footprints.iter().any(|f| f.reference == c.reference) || c.footprint.is_empty() {
continue;
}
let m = match module(&c.footprint) {
Ok(m) => m,
Err(e) => {
push(&mut done, "notApplied", json!({"ref": c.reference, "change": "add", "footprint": c.footprint, "reason": e}));
continue;
}
};
let net = |pad: &str| want.get(&(c.reference.clone(), pad.to_string())).cloned();
// measure first at the origin, then place so the courtyard's left edge sits at x
let probe = instantiate(&m, &c.footprint, &c.reference, &c.value, (0.0, 0.0), 0.0, "F.Cu", &new_uuid(), None, &net, fmt, &numbers)?;
let parsed = pcb::parse_pcb_text(&format!("(kicad_pcb\n{probe}\n)"), "probe").map_err(|e| RoutingError::new("library_unparsable", e))?;
let (r, _) = parsed.footprints.first().map(|f| placement::courtyard_at(f, (0.0, 0.0), 0.0)).unwrap_or((placement::Rect { min_x: -1.0, min_y: -1.0, max_x: 1.0, max_y: 1.0 }, "position"));
let at = (pcb::round6(x - r.min_x), pcb::round6(y0 - r.min_y));
let block = instantiate(&m, &c.footprint, &c.reference, &c.value, at, 0.0, "F.Cu", &new_uuid(), symbol_path(c).as_deref(), &net, fmt, &numbers)?;
added_blocks.push('\n');
added_blocks.push_str(&block);
push(&mut done, "footprintsAdded", json!({"ref": c.reference, "footprint": c.footprint, "x": at.0, "y": at.1}));
x += r.width() + 1.0;
}
if !added_blocks.is_empty() {
let fps = footprint_spans(&out);
let at = fps.last().map(|(_, e)| *e).unwrap_or_else(|| out.trim_end().len() - 1);
out.insert_str(at, &added_blocks);
}
Ok((out, done))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_reference_is_set_however_the_library_spaced_it() {
// wiki #138: each of these kept REF** and then read as a missing part
let pad = "(pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\"))";
for (name, field) in [
("KiCad's own", "(property \"Reference\" \"REF**\" (at 0 0 0) (layer \"F.SilkS\"))"),
("two spaces", "(property \"Reference\" \"REF**\" (at 0 0 0) (layer \"F.SilkS\"))"),
("value on its own line", "(property \"Reference\"\n\t\t\"REF**\"\n\t\t(at 0 0 0) (layer \"F.SilkS\"))"),
("CRLF", "(property \"Reference\"\r\n\t\"REF**\"\r\n\t(at 0 0 0) (layer \"F.SilkS\"))"),
("each token on a line", "(property\n\t\t\"Reference\"\n\t\t\"REF**\"\n\t\t(at 0 0 0) (layer \"F.SilkS\"))"),
("bare words", "(property Reference REF** (at 0 0 0) (layer \"F.SilkS\"))"),
("KiCad 6's fp_text", "(fp_text reference \"REF**\" (at 0 0) (layer \"F.SilkS\"))"),
("KiCad 5's fp_text, bare", "(fp_text reference REF** (at 0 0) (layer F.SilkS))"),
("no Reference at all", ""),
] {
let module = format!("(footprint \"CR0402-FX-1002GLF\" (layer \"F.Cu\") {field} (property \"Value\" \"X\" (at 0 1 0) (layer \"F.Fab\")) {pad})");
let b = instantiate(&module, "PCM_lib:CR0402-FX-1002GLF", "R15", "10k", (1.0, 2.0), 0.0, "F.Cu", "u-1", Some("/abc"), &|_| None, NetFormat::Name, &BTreeMap::new()).unwrap_or_else(|e| panic!("{name}: {}", e.message));
assert!(!b.contains("REF**"), "{name}: {b}");
let parsed = pcb::parse_pcb_text(&format!("(kicad_pcb\n{b}\n)"), "t").unwrap();
assert_eq!(parsed.footprints[0].reference, "R15", "{name}: {b}");
assert_eq!(parsed.footprints[0].value, "10k", "{name}: {b}");
}
}
#[test]
fn an_escaped_board_net_is_the_netlists_plain_name() {
// pic_programmer: the label VPP/MCLR is /VPP{slash}MCLR on the board and /VPP/MCLR in the netlist
assert_eq!(unescape_net("/VPP{slash}MCLR"), "/VPP/MCLR");
assert_eq!(unescape_net("unconnected-(U5-OSC1{slash}CLKI-Pad16)"), "unconnected-(U5-OSC1/CLKI-Pad16)");
assert_eq!(unescape_net("GND"), "GND");
assert_eq!(unescape_net("A{lt}B{gt}{colon}C"), "A<B>:C");
}
#[test]
fn a_pad_net_is_set_replaced_and_cleared() {
let block = "(footprint \"L:R\"\n\t\t(layer \"F.Cu\")\n\t\t(pad \"1\" smd rect\n\t\t\t(at 0 0)\n\t\t\t(net \"A\")\n\t\t\t(uuid \"u1\")\n\t\t)\n\t\t(pad \"2\" smd rect\n\t\t\t(at 1 0)\n\t\t\t(uuid \"u2\")\n\t\t)\n\t)";
let m = BTreeMap::new();
let b = set_pad_net(block, "1", Some("B"), NetFormat::Name, &m);
assert!(b.contains("(net \"B\")") && !b.contains("(net \"A\")"));
let b = set_pad_net(&b, "2", Some("C"), NetFormat::Name, &m);
assert!(b.contains("(net \"C\")\n\t\t\t(uuid \"u2\")"), "{b}");
let b = set_pad_net(&b, "1", None, NetFormat::Name, &m);
assert!(!b.contains("(net \"B\")"));
let mut nums = BTreeMap::new();
nums.insert("C".to_string(), 7);
assert!(set_pad_net(block, "1", Some("C"), NetFormat::Number, &nums).contains("(net 7 \"C\")"));
}
#[test]
fn angles_add_to_pads_and_properties() {
let block = "(footprint \"L:R\"\n\t(property \"Reference\" \"R1\" (at 0 -1 0))\n\t(pad \"1\" smd rect (at 1 0) (size 1 1))\n\t(fp_line (start 0 0) (end 1 1))\n)";
let b = add_angle(block, 90.0);
assert!(b.contains("(property \"Reference\" \"R1\" (at 0 -1 90))"), "{b}");
assert!(b.contains("(pad \"1\" smd rect (at 1 0 90)"), "{b}");
assert!(b.contains("(fp_line (start 0 0) (end 1 1))"));
}
#[test]
fn the_symbol_path_joins_sheet_and_uuid() {
let c = Component { reference: "R1".into(), tstamp: "abc".into(), sheet_tstamps: "/".into(), ..Default::default() };
assert_eq!(symbol_path(&c).as_deref(), Some("/abc"));
let c = Component { tstamp: "abc".into(), sheet_tstamps: "/s1".into(), ..Default::default() };
assert_eq!(symbol_path(&c).as_deref(), Some("/s1/abc"));
}
}