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 #357: kicad-lane: 1.0.90, swap_footprint, plate_vias, normalize_bodies, symbol_mode, explained refusals (#29)
e955f3c
11d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001
//! Footprint authoring on `.kicad_mod` files (KiCad 10 format): create one from a pad
//! list (courtyard, fab and silk outlines, reference and value generated), read one back
//! in full, and edit pads, graphics, metadata and 3D models in place. Edits go through the
//! lossless document model in `crate::schematic`, so everything an edit does not touch
//! stays byte-identical; new nodes are laid out the way KiCad's formatter does.
use serde_json::{json, Value};
use crate::pcb::num;
use crate::schematic::{new_uuid, parse_node, pretty, quote, Document, Kind, Node};
pub const FP_VERSION: &str = "20260206";
const GENERATOR: &str = "adom_kicad_bridge";
fn xy(v: Option<&Value>) -> Option<(f64, f64)> {
match v? {
Value::Array(a) if a.len() >= 2 => Some((a[0].as_f64()?, a[1].as_f64()?)),
Value::Object(o) => Some((o.get("x")?.as_f64()?, o.get("y")?.as_f64()?)),
_ => None,
}
}
fn xyz(v: Option<&Value>, default: f64) -> Result<[f64; 3], String> {
match v {
None | Some(Value::Null) => Ok([default; 3]),
Some(Value::Array(a)) if a.len() == 3 => {
let mut out = [0.0; 3];
for (i, x) in a.iter().enumerate() {
out[i] = x.as_f64().ok_or("xyz values must be numbers")?;
}
Ok(out)
}
Some(Value::Object(o)) => Ok([o.get("x").and_then(Value::as_f64).unwrap_or(default), o.get("y").and_then(Value::as_f64).unwrap_or(default), o.get("z").and_then(Value::as_f64).unwrap_or(default)]),
_ => Err("offset, scale and rotate take [x, y, z] or {x, y, z}".into()),
}
}
// ---------------------------------------------------------------------------
// Pads
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, PartialEq)]
pub struct PadSpec {
pub number: String,
pub kind: String,
pub shape: String,
pub at: (f64, f64),
pub rotation: f64,
pub size: (f64, f64),
pub drill: Option<(f64, f64)>,
pub layers: Vec<String>,
pub rratio: Option<f64>,
}
pub const PAD_TYPES: &[&str] = &["smd", "thru_hole", "np_thru_hole", "connect"];
pub const PAD_SHAPES: &[&str] = &["circle", "rect", "oval", "roundrect", "trapezoid"];
/// One pad from the verb's JSON: {number, type, shape, size [w,h] | d, drill d | [w,h],
/// at [x,y], rotation, layers, roundrectRatio}. Defaults: smd roundrect 0.25, layers by type.
pub fn pad_from_json(v: &Value, side_back: bool) -> Result<PadSpec, String> {
let number = match v.get("number") {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
None | Some(Value::Null) => String::new(),
_ => return Err("pad number must be a string or a number".into()),
};
let kind = v.get("type").and_then(Value::as_str).unwrap_or("smd").to_string();
if !PAD_TYPES.contains(&kind.as_str()) {
return Err(format!("pad {number}: type must be one of {PAD_TYPES:?}"));
}
let shape = v.get("shape").and_then(Value::as_str).unwrap_or(if kind == "smd" { "roundrect" } else { "circle" }).to_string();
if !PAD_SHAPES.contains(&shape.as_str()) {
return Err(format!("pad {number}: shape must be one of {PAD_SHAPES:?}"));
}
let size = match v.get("size") {
Some(Value::Number(n)) => {
let d = n.as_f64().unwrap_or(0.0);
(d, d)
}
other => xy(other).ok_or_else(|| format!("pad {number}: size [w, h] (mm) is required"))?,
};
if size.0 <= 0.0 || size.1 <= 0.0 {
return Err(format!("pad {number}: size must be positive"));
}
let drill = match v.get("drill") {
None | Some(Value::Null) => None,
Some(Value::Number(n)) => n.as_f64().map(|d| (d, d)),
other => Some(xy(other).ok_or_else(|| format!("pad {number}: drill is a diameter or [w, h]"))?),
};
if kind != "smd" && kind != "connect" && drill.is_none() {
return Err(format!("pad {number}: a {kind} pad needs a drill"));
}
if let Some(d) = drill {
if d.0 <= 0.0 || d.1 <= 0.0 {
return Err(format!("pad {number}: drill must be positive"));
}
if kind == "thru_hole" && (d.0 >= size.0 || d.1 >= size.1) {
return Err(format!("pad {number}: the drill ({}) must be smaller than the pad ({} x {})", num(d.0), num(size.0), num(size.1)));
}
}
let at = xy(v.get("at").or_else(|| v.get("position"))).unwrap_or((0.0, 0.0));
let rotation = v.get("rotation").and_then(Value::as_f64).unwrap_or(0.0);
let f = if side_back { "B" } else { "F" };
let layers = match v.get("layers") {
Some(Value::Array(a)) => a.iter().filter_map(Value::as_str).map(str::to_string).collect(),
_ => match kind.as_str() {
"smd" => vec![format!("{f}.Cu"), format!("{f}.Mask"), format!("{f}.Paste")],
"connect" => vec![format!("{f}.Cu"), format!("{f}.Mask")],
"thru_hole" => vec!["*.Cu".into(), "*.Mask".into()],
_ => vec!["*.Cu".into(), "*.Mask".into()],
},
};
let rratio = if shape == "roundrect" { Some(v.get("roundrectRatio").and_then(Value::as_f64).unwrap_or(0.25).clamp(0.0, 0.5)) } else { None };
Ok(PadSpec { number, kind, shape, at, rotation, size, drill, layers, rratio })
}
pub fn pad_text(p: &PadSpec) -> String {
let rot = if p.rotation != 0.0 { format!(" {}", num(p.rotation)) } else { String::new() };
let drill = match p.drill {
Some((a, b)) if (a - b).abs() > 1e-9 => format!("(drill oval {} {})", num(a), num(b)),
Some((a, _)) => format!("(drill {})", num(a)),
None => String::new(),
};
let layers: Vec<String> = p.layers.iter().map(|l| quote(l)).collect();
let rr = p.rratio.map(|r| format!("(roundrect_rratio {})", num(r))).unwrap_or_default();
let extra = if p.kind == "thru_hole" { "(remove_unused_layers no)" } else { "" };
format!(
"(pad {} {} {} (at {} {}{rot}) (size {} {}) {drill} (layers {}) {extra} {rr} (uuid \"{}\"))",
quote(&p.number), p.kind, p.shape, num(p.at.0), num(p.at.1), num(p.size.0), num(p.size.1), layers.join(" "), new_uuid()
)
}
/// The axis-aligned box of a pad (rotation applied).
fn pad_box(p: &PadSpec) -> (f64, f64, f64, f64) {
let a = p.rotation.to_radians();
let (hw, hh) = (p.size.0 / 2.0, p.size.1 / 2.0);
let ex = (hw * a.cos()).abs() + (hh * a.sin()).abs();
let ey = (hw * a.sin()).abs() + (hh * a.cos()).abs();
(p.at.0 - ex, p.at.1 - ey, p.at.0 + ex, p.at.1 + ey)
}
fn union(a: (f64, f64, f64, f64), b: (f64, f64, f64, f64)) -> (f64, f64, f64, f64) {
(a.0.min(b.0), a.1.min(b.1), a.2.max(b.2), a.3.max(b.3))
}
fn snap_out(b: (f64, f64, f64, f64), grid: f64) -> (f64, f64, f64, f64) {
((b.0 / grid).floor() * grid, (b.1 / grid).floor() * grid, (b.2 / grid).ceil() * grid, (b.3 / grid).ceil() * grid)
}
/// Node from compact text, laid out at depth 1 (a direct child of the footprint).
fn node(text: &str) -> Result<Node, String> {
let mut n = parse_node(text)?;
pretty(&mut n, 1);
Ok(n)
}
// ---------------------------------------------------------------------------
// Graphics
// ---------------------------------------------------------------------------
/// One graphic from JSON: {type: line|arc|rect|circle|poly, start, end, mid, center, points,
/// width, fill}. Returns the fp_* text on `layer`.
pub fn graphic_text(v: &Value, layer: &str, default_width: f64) -> Result<String, String> {
let t = v.get("type").and_then(Value::as_str).ok_or("each shape needs a type: line, arc, rect, circle or poly")?;
let w = v.get("width").and_then(Value::as_f64).unwrap_or(default_width);
let fill = match v.get("fill") {
Some(Value::Bool(true)) => "yes",
Some(Value::String(s)) if s == "yes" || s == "solid" => "yes",
_ => "no",
};
let stroke = format!("(stroke (width {}) (type solid))", num(w));
let p = |k: &str| xy(v.get(k)).ok_or_else(|| format!("{t}: {k} [x, y] is required"));
let uuid = new_uuid();
let lay = quote(layer);
Ok(match t {
"line" => {
let (a, b) = (p("start")?, p("end")?);
format!("(fp_line (start {} {}) (end {} {}) {stroke} (layer {lay}) (uuid \"{uuid}\"))", num(a.0), num(a.1), num(b.0), num(b.1))
}
"arc" => {
let (a, m, b) = (p("start")?, p("mid")?, p("end")?);
format!("(fp_arc (start {} {}) (mid {} {}) (end {} {}) {stroke} (layer {lay}) (uuid \"{uuid}\"))", num(a.0), num(a.1), num(m.0), num(m.1), num(b.0), num(b.1))
}
"rect" => {
let (a, b) = (p("start")?, p("end")?);
format!("(fp_rect (start {} {}) (end {} {}) {stroke} (fill {fill}) (layer {lay}) (uuid \"{uuid}\"))", num(a.0), num(a.1), num(b.0), num(b.1))
}
"circle" => {
let c = p("center")?;
let e = match (xy(v.get("end")), v.get("radius").and_then(Value::as_f64)) {
(Some(e), _) => e,
(None, Some(r)) => (c.0 + r, c.1),
_ => return Err("circle: end [x, y] or radius is required".into()),
};
format!("(fp_circle (center {} {}) (end {} {}) {stroke} (fill {fill}) (layer {lay}) (uuid \"{uuid}\"))", num(c.0), num(c.1), num(e.0), num(e.1))
}
"poly" => {
let pts: Vec<(f64, f64)> = v.get("points").and_then(Value::as_array).map(|a| a.iter().filter_map(|q| xy(Some(q))).collect()).unwrap_or_default();
if pts.len() < 3 {
return Err("poly: points needs at least three [x, y]".into());
}
let xys: Vec<String> = pts.iter().map(|(x, y)| format!("(xy {} {})", num(*x), num(*y))).collect();
format!("(fp_poly (pts {}) {stroke} (fill {fill}) (layer {lay}) (uuid \"{uuid}\"))", xys.join(" "))
}
other => return Err(format!("unknown shape type {other}: line, arc, rect, circle or poly")),
})
}
fn rect_text(b: (f64, f64, f64, f64), layer: &str, width: f64) -> String {
format!("(fp_rect (start {} {}) (end {} {}) (stroke (width {}) (type solid)) (fill no) (layer {}) (uuid \"{}\"))", num(b.0), num(b.1), num(b.2), num(b.3), num(width), quote(layer), new_uuid())
}
fn property_text(name: &str, value: &str, at: (f64, f64), layer: &str, hide: bool, size: f64) -> String {
format!(
"(property {} {} (at {} {} 0) (layer {}){} (uuid \"{}\") (effects (font (size {} {}) (thickness {}))))",
quote(name), quote(value), num(at.0), num(at.1), quote(layer), if hide { " (hide yes)" } else { "" }, new_uuid(), num(size), num(size), num((size * 0.15 * 100.0).round() / 100.0)
)
}
fn model_text(m: &Value) -> Result<String, String> {
let path = m.get("path").and_then(Value::as_str).filter(|p| !p.trim().is_empty()).ok_or("a model needs a path")?;
let o = xyz(m.get("offset"), 0.0)?;
let s = xyz(m.get("scale"), 1.0)?;
let r = xyz(m.get("rotate").or_else(|| m.get("rotation")), 0.0)?;
let t = |v: [f64; 3]| format!("(xyz {} {} {})", num(v[0]), num(v[1]), num(v[2]));
Ok(format!("(model {} (offset {}) (scale {}) (rotate {}))", quote(path), t(o), t(s), t(r)))
}
// ---------------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------------
/// A complete .kicad_mod from the verb's args. Returns (text, summary).
pub fn create(name: &str, args: &Value) -> Result<(String, Value), String> {
if name.trim().is_empty() || name.contains(['/', '\\', ':', '"']) {
return Err("name must be a plain footprint name (no / \\ : or quotes)".into());
}
let back = args.get("side").and_then(Value::as_str) == Some("back");
let pads_v = args.get("pads").and_then(Value::as_array).ok_or("pads is required: a list of {number, type, shape, size, at, drill?}")?;
if pads_v.is_empty() {
return Err("pads is empty".into());
}
let pads: Vec<PadSpec> = pads_v.iter().map(|p| pad_from_json(p, back)).collect::<Result<_, _>>()?;
let pad_bb = pads.iter().map(pad_box).reduce(union).unwrap();
let body = match xy(args.get("bodySize")) {
Some((w, h)) => (-w / 2.0, -h / 2.0, w / 2.0, h / 2.0),
None => pad_bb,
};
let margin = args.get("courtyardMargin").and_then(Value::as_f64).unwrap_or(0.25).max(0.0);
let all = union(pad_bb, body);
let silk_on = args.get("silk").and_then(Value::as_bool).unwrap_or(true);
// Silkscreen box: 0.2 mm clear of every pad (never over copper), stroke 0.12 mm.
let silk_box = union(body, (pad_bb.0 - 0.26, pad_bb.1 - 0.26, pad_bb.2 + 0.26, pad_bb.3 + 0.26));
let mut crt = (all.0 - margin, all.1 - margin, all.2 + margin, all.3 + margin);
if silk_on {
// the courtyard also encloses the silkscreen (and the pin-1 dot left of it)
crt = union(crt, (silk_box.0 - 0.5, silk_box.1 - 0.12, silk_box.2 + 0.12, silk_box.3 + 0.12));
}
let crt = snap_out(crt, 0.01);
// explicit graphics (footprint_from_land_pattern's layout): they replace the generated silk box,
// fab rectangle and courtyard; layer is silk | fab | courtyard
let given = args.get("graphics").and_then(Value::as_array).filter(|g| !g.is_empty());
let crt = match (given, args.get("courtyardRect").and_then(Value::as_array)) {
(Some(_), Some(c)) if c.len() == 4 => (c[0].as_f64().unwrap_or(crt.0), c[1].as_f64().unwrap_or(crt.1), c[2].as_f64().unwrap_or(crt.2), c[3].as_f64().unwrap_or(crt.3)),
_ => crt,
};
let f = if back { "B" } else { "F" };
let smd = pads.iter().all(|p| p.kind == "smd" || p.kind == "connect");
let attr = match args.get("attr").and_then(Value::as_str) {
Some(a) if ["smd", "through_hole", "unspecified"].contains(&a) => a.to_string(),
Some(a) => return Err(format!("attr must be smd, through_hole or unspecified (got {a})")),
None => if smd { "smd".into() } else { "through_hole".into() },
};
let mut parts: Vec<String> = Vec::new();
parts.push(format!("(version {FP_VERSION})"));
parts.push(format!("(generator {})", quote(GENERATOR)));
parts.push("(generator_version \"10.0\")".into());
parts.push(format!("(layer \"{f}.Cu\")"));
if let Some(d) = args.get("description").and_then(Value::as_str) {
parts.push(format!("(descr {})", quote(d)));
}
if let Some(t) = args.get("tags") {
let t = match t {
Value::Array(a) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "),
Value::String(s) => s.clone(),
_ => String::new(),
};
if !t.is_empty() {
parts.push(format!("(tags {})", quote(&t)));
}
}
let text_size = 1.0;
parts.push(property_text("Reference", args.get("reference").and_then(Value::as_str).unwrap_or("REF**"), (0.0, crt.1 - 0.8), &format!("{f}.SilkS"), false, text_size));
parts.push(property_text("Value", args.get("value").and_then(Value::as_str).unwrap_or(name), (0.0, crt.3 + 0.8), &format!("{f}.Fab"), false, text_size));
parts.push(property_text("Datasheet", args.get("datasheet").and_then(Value::as_str).unwrap_or(""), (0.0, 0.0), &format!("{f}.Fab"), true, text_size));
parts.push(property_text("Description", args.get("description").and_then(Value::as_str).unwrap_or(""), (0.0, 0.0), &format!("{f}.Fab"), true, text_size));
parts.push(format!("(attr {attr})"));
parts.push("(duplicate_pad_numbers_are_jumpers no)".into());
if let Some(g) = given {
for e in g {
let layer = match e["layer"].as_str().unwrap_or("silk") {
"fab" => format!("{f}.Fab"),
"courtyard" => format!("{f}.CrtYd"),
_ => format!("{f}.SilkS"),
};
let p = |k: &str| -> Result<(f64, f64), String> { xy(e.get(k)).ok_or_else(|| format!("a graphic needs {k}: [x, y]")) };
if e.get("r").is_some() {
let (c, r) = (p("center")?, e["r"].as_f64().unwrap_or(0.15));
parts.push(format!("(fp_circle (center {} {}) (end {} {}) (stroke (width {}) (type solid)) (fill yes) (layer {}) (uuid \"{}\"))", num(c.0), num(c.1), num(c.0 + r / 2.0), num(c.1), num(r), quote(&layer), new_uuid()));
} else {
let (a, b) = (p("start")?, p("end")?);
parts.push(format!("(fp_line (start {} {}) (end {} {}) (stroke (width {}) (type solid)) (layer {}) (uuid \"{}\"))", num(a.0), num(a.1), num(b.0), num(b.1), num(e["width"].as_f64().unwrap_or(0.12)), quote(&layer), new_uuid()));
}
}
}
// Silkscreen: a box clear of every pad by 0.2 mm (never over copper), and a pin-1 dot.
if silk_on && given.is_none() {
let sb = silk_box;
parts.push(rect_text(sb, &format!("{f}.SilkS"), 0.12));
if let Some(p1) = pads.iter().find(|p| p.number == "1") {
let (cx, cy) = (sb.0 - 0.3, p1.at.1.clamp(sb.1, sb.3));
parts.push(format!("(fp_circle (center {} {}) (end {} {}) (stroke (width 0.12) (type solid)) (fill yes) (layer \"{f}.SilkS\") (uuid \"{}\"))", num(cx), num(cy), num(cx + 0.1), num(cy), new_uuid()));
}
}
if given.is_none() {
parts.push(rect_text(body, &format!("{f}.Fab"), 0.1));
parts.push(rect_text(crt, &format!("{f}.CrtYd"), 0.05));
}
parts.push(format!("(fp_text user \"${{REFERENCE}}\" (at 0 0 0) (layer \"{f}.Fab\") (uuid \"{}\") (effects (font (size 0.5 0.5) (thickness 0.08))))", new_uuid()));
for p in &pads {
parts.push(pad_text(p));
}
parts.push("(embedded_fonts no)".into());
if let Some(m) = args.get("model") {
if !m.is_null() {
parts.push(model_text(m)?);
}
}
let mut root = parse_node(&format!("(footprint {} {})", quote(name), parts.join(" ")))?;
pretty(&mut root, 0);
root.lead.clear();
let text = root.to_text() + "\n";
crate::lint::check_footprint_structure(&text)?;
let info = info(&text)?;
Ok((text, json!({"pads": pads.len(), "attr": attr, "courtyard": {"min": [crt.0, crt.1], "max": [crt.2, crt.3]}, "info": info})))
}
// ---------------------------------------------------------------------------
// Info
// ---------------------------------------------------------------------------
fn nums(n: &Node) -> Vec<f64> {
n.children().iter().skip(1).filter_map(|c| c.value()?.parse().ok()).collect()
}
/// Full details of one footprint file.
pub fn info(text: &str) -> Result<Value, String> {
let doc = Document::parse(text)?;
let r = &doc.root;
if r.head() != Some("footprint") && r.head() != Some("module") {
return Err(format!("not a footprint (top-level list is {:?})", r.head()));
}
let mut props = serde_json::Map::new();
let mut pads = Vec::new();
let mut graphics: std::collections::BTreeMap<String, usize> = Default::default();
let mut layers: std::collections::BTreeSet<String> = Default::default();
let mut crt: Option<(f64, f64, f64, f64)> = None;
let mut models = Vec::new();
let grow = |b: &mut Option<(f64, f64, f64, f64)>, x: f64, y: f64| {
*b = Some(match *b {
None => (x, y, x, y),
Some(o) => (o.0.min(x), o.1.min(y), o.2.max(x), o.3.max(y)),
});
};
for c in r.children() {
match c.head() {
Some("property") => {
if let (Some(k), Some(v)) = (c.arg(0), c.arg(1)) {
props.insert(k, json!(v));
}
}
Some("pad") => {
let at = c.child("at").map(nums).unwrap_or_default();
let size = c.child("size").map(nums).unwrap_or_default();
let drill = c.child("drill").map(nums);
let pl: Vec<String> = c.child("layers").map(|l| l.children().iter().skip(1).filter_map(Node::value).collect()).unwrap_or_default();
layers.extend(pl.iter().cloned());
pads.push(json!({
"number": c.arg(0), "type": c.arg(1), "shape": c.arg(2),
"at": at.get(..2), "rotation": at.get(2).copied().unwrap_or(0.0),
"size": size, "drill": drill, "layers": pl,
"roundrectRatio": c.child("roundrect_rratio").and_then(|n| n.num(0)),
}));
}
Some(h) if h.starts_with("fp_") => {
let layer = c.child("layer").and_then(|l| l.arg(0)).unwrap_or_default();
layers.insert(layer.clone());
*graphics.entry(format!("{layer} {}", &h[3..])).or_default() += 1;
if layer.ends_with("CrtYd") {
for k in ["start", "end", "mid", "center"] {
if let Some(p) = c.child(k) {
if let (Some(x), Some(y)) = (p.num(0), p.num(1)) {
grow(&mut crt, x, y);
}
}
}
if h == "fp_circle" {
if let (Some(cn), Some(e)) = (c.child("center"), c.child("end")) {
let (cx, cy) = (cn.num(0).unwrap_or(0.0), cn.num(1).unwrap_or(0.0));
let rr = ((e.num(0).unwrap_or(0.0) - cx).powi(2) + (e.num(1).unwrap_or(0.0) - cy).powi(2)).sqrt();
grow(&mut crt, cx - rr, cy - rr);
grow(&mut crt, cx + rr, cy + rr);
}
}
if let Some(pts) = c.child("pts") {
for q in pts.children().iter().filter(|q| q.head() == Some("xy")) {
if let (Some(x), Some(y)) = (q.num(0), q.num(1)) {
grow(&mut crt, x, y);
}
}
}
}
}
Some("model") => {
let t = |k: &str| c.child(k).and_then(|o| o.child("xyz")).map(nums);
models.push(json!({"path": c.arg(0), "offset": t("offset"), "scale": t("scale"), "rotate": t("rotate"), "hidden": c.child("hide").is_some()}));
}
_ => {}
}
}
let attrs: Vec<String> = r.child("attr").map(|a| a.children().iter().skip(1).filter_map(Node::value).collect()).unwrap_or_default();
Ok(json!({
"name": r.arg(0),
"version": r.child_value("version"),
"layer": r.child_value("layer"),
"description": r.child_value("descr"),
"tags": r.child_value("tags"),
"attributes": attrs,
"properties": props,
"padCount": pads.len(),
"pads": pads,
"layers": layers.into_iter().filter(|l| !l.is_empty()).collect::<Vec<_>>(),
"graphics": graphics,
"courtyard": crt.map(|b| json!({"min": [b.0, b.1], "max": [b.2, b.3], "widthMm": ((b.2 - b.0) * 1e6).round() / 1e6, "heightMm": ((b.3 - b.1) * 1e6).round() / 1e6})),
"models": models,
}))
}
/// The standard's plate_vias on one footprint: every drilled pad becomes a plated hole through every copper layer
/// (`thru_hole`, layers `*.Cu` `*.Mask`) when it is not one already: a `thru_hole` pad on only some copper layers, or an
/// `smd` pad with a drill. A numbered unplated hole (`np_thru_hole`) is plated too when `include_npth`; otherwise it is
/// listed in `unplated` (a mounting hole is meant to be unplated). Returns the new text, the pads fixed (with what each
/// was), the holes seen and the unplated numbered holes left alone.
pub fn plate_vias(text: &str, include_npth: bool) -> Result<(String, Vec<Value>, usize, Vec<String>), String> {
let mut doc = Document::parse(text)?;
let mut fixed = Vec::new();
let mut unplated = Vec::new();
let mut holes = 0;
for c in doc.root.children_mut().into_iter().flatten().filter(|c| c.head() == Some("pad")) {
if c.child("drill").is_none() {
continue;
}
holes += 1;
let num = c.arg(0).unwrap_or_default();
let kind = c.arg(1).unwrap_or_default();
let layers: Vec<String> = c.child("layers").map(|l| l.children().iter().skip(1).filter_map(Node::value).collect()).unwrap_or_default();
let through = layers.iter().any(|l| l == "*.Cu") || (layers.iter().any(|l| l == "F.Cu") && layers.iter().any(|l| l == "B.Cu"));
let fix = match kind.as_str() {
"thru_hole" => !through,
"smd" | "connect" => true,
"np_thru_hole" => {
if num.is_empty() {
false
} else if include_npth {
true
} else {
unplated.push(num.clone());
false
}
}
_ => false,
};
if !fix {
continue;
}
c.set_arg(1, Node::atom("thru_hole"));
let mut l = Node::list("layers", vec![Node::quoted("*.Cu"), Node::quoted("*.Mask")]);
if let Some(old) = c.child_mut("layers") {
l.lead = old.lead.clone();
*old = l;
}
fixed.push(json!({"pad": num, "was": kind, "layers": layers}));
}
Ok((doc.to_text(), fixed, holes, unplated))
}
/// The standard's normalize_bodies on one footprint: a 3D model whose body sits more than `min_shift` mm from the
/// footprint's pad cluster (centre to centre, in the plane) is moved onto it by its offset. `resolve` gives a model
/// file's bounds. Returns the new text, the models moved ({index, path, shiftMm, offsetBefore, offsetAfter}) and the
/// models left as they are (with why).
pub fn normalize_bodies(text: &str, min_shift: f64, resolve: &mut dyn FnMut(&str) -> Result<crate::bodies::Bounds, String>) -> Result<(String, Vec<Value>, Vec<Value>), String> {
let inf = info(text)?;
let mut pb: Option<(f64, f64, f64, f64)> = None;
for p in inf["pads"].as_array().into_iter().flatten() {
let (x, y) = (p["at"][0].as_f64().unwrap_or(0.0), p["at"][1].as_f64().unwrap_or(0.0));
let (w, h) = (p["size"][0].as_f64().unwrap_or(0.0) / 2.0, p["size"][1].as_f64().unwrap_or(0.0) / 2.0);
let e = (x - w, y - h, x + w, y + h);
pb = Some(pb.map_or(e, |o| (o.0.min(e.0), o.1.min(e.1), o.2.max(e.2), o.3.max(e.3))));
}
let Some(pb) = pb else { return Ok((text.to_string(), vec![], vec![json!({"why": "the footprint has no pads to centre on"})])) };
// the 3D frame is y up; the footprint file's y is down
let (pcx, pcy) = ((pb.0 + pb.2) / 2.0, -(pb.1 + pb.3) / 2.0);
let sx = crate::model_check::parse_sexpr(text).ok_or("empty footprint text")?;
let refs = crate::bodies::model_refs(&sx);
let mut doc = Document::parse(text)?;
let (mut moved, mut kept) = (Vec::new(), Vec::new());
let r4 = |v: f64| (v * 1e4).round() / 1e4 + 0.0;
let mut model_nodes: Vec<&mut Node> = doc.root.children_mut().into_iter().flatten().filter(|c| c.head() == Some("model")).collect();
for (i, m) in refs.iter().enumerate() {
if m.hidden {
kept.push(json!({"index": i, "path": m.path, "why": "hidden"}));
continue;
}
let b = match resolve(&m.path) {
Ok(b) => b,
Err(e) => {
kept.push(json!({"index": i, "path": m.path, "why": e}));
continue;
}
};
let local = crate::bodies::in_footprint(&b, m);
let (bcx, bcy) = ((local.min[0] + local.max[0]) / 2.0, (local.min[1] + local.max[1]) / 2.0);
let (dx, dy) = (pcx - bcx, pcy - bcy);
let dist = dx.hypot(dy);
if dist <= min_shift {
kept.push(json!({"index": i, "path": m.path, "why": format!("its body is {:.3} mm from the pads' centre (within {min_shift} mm)", dist)}));
continue;
}
let after = [r4(m.offset[0] + dx), r4(m.offset[1] + dy), m.offset[2]];
let Some(node) = model_nodes.get_mut(i) else { continue };
let mut off = Node::list("offset", vec![Node::list("xyz", vec![Node::number(after[0]), Node::number(after[1]), Node::number(after[2])])]);
pretty(&mut off, 2);
match node.child_mut("offset") {
Some(o) => {
*o = off;
}
None => {
if let Some(c) = node.children_mut() {
c.insert(1.min(c.len()) + 1, off);
}
}
}
moved.push(json!({"index": i, "path": m.path, "shiftMm": r4(dist), "offsetBefore": m.offset, "offsetAfter": after}));
}
Ok((doc.to_text(), moved, kept))
}
// ---------------------------------------------------------------------------
// Edit
// ---------------------------------------------------------------------------
fn kids(root: &mut Node) -> &mut Vec<Node> {
root.children_mut().expect("root is a list")
}
/// Index after the last child whose head is in `after`; else before the first in `before`;
/// else before the closing paren.
fn insert_at(root: &Node, after: &[&str], before: &[&str]) -> usize {
let c = root.children();
if let Some(i) = c.iter().rposition(|n| n.head().map(|h| after.contains(&h)).unwrap_or(false)) {
return i + 1;
}
if let Some(i) = c.iter().position(|n| n.head().map(|h| before.contains(&h)).unwrap_or(false)) {
return i;
}
c.len()
}
const GRAPHIC_HEADS: &[&str] = &["fp_line", "fp_arc", "fp_rect", "fp_circle", "fp_poly", "fp_curve"];
fn set_child(root: &mut Node, head: &str, text: &str, after: &[&str]) -> Result<(), String> {
let new = node(text)?;
if let Some(i) = root.children().iter().position(|c| c.head() == Some(head)) {
let lead = root.children()[i].lead.clone();
kids(root)[i] = new;
kids(root)[i].lead = lead;
} else {
let i = insert_at(root, after, &["property", "attr", "fp_line", "pad"]);
kids(root).insert(i, new);
}
Ok(())
}
/// Apply the edit operations in `args` to footprint text. Returns (new text, changes).
/// Operations: pads (edit, delete, add), renumber, graphics, metadata, models.
pub fn edit(text: &str, args: &Value) -> Result<(String, Vec<String>), String> {
let mut doc = Document::parse(text)?;
if doc.root.head() != Some("footprint") {
return Err("not a KiCad 6+ footprint (the top-level list must be footprint)".into());
}
let mut changes: Vec<String> = Vec::new();
let back = doc.root.child_value("layer").as_deref() == Some("B.Cu");
// pads: [{number, delete?, set?:{...}}], addPads: [padSpec]
if let Some(list) = args.get("pads").and_then(Value::as_array) {
for op in list {
let want = match op.get("number") {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
_ => return Err("each pads entry needs the number of the pad it edits".into()),
};
let idxs: Vec<usize> = doc.root.children().iter().enumerate().filter(|(_, c)| c.head() == Some("pad") && c.arg(0).as_deref() == Some(want.as_str())).map(|(i, _)| i).collect();
if idxs.is_empty() {
return Err(format!("no pad numbered {want}"));
}
if op.get("delete").and_then(Value::as_bool).unwrap_or(false) {
for i in idxs.iter().rev() {
kids(&mut doc.root).remove(*i);
}
changes.push(format!("deleted pad {want} ({} pad(s))", idxs.len()));
continue;
}
let set = op.get("set").unwrap_or(op);
for &i in &idxs {
let pad = &mut kids(&mut doc.root)[i];
let mut cur = pad_from_node(pad)?;
if let Some(v) = set.get("newNumber").or_else(|| set.get("renumber")) {
cur.number = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
}
let merged = merge_pad(&cur, set, back)?;
let mut fresh = node(&pad_text(&merged))?;
// keep the pad's uuid and any child the bridge does not model (net, pinfunction, ...)
carry_unmodelled(pad, &mut fresh);
fresh.lead = pad.lead.clone();
*pad = fresh;
}
changes.push(format!("edited pad {want}"));
}
}
if let Some(list) = args.get("addPads").and_then(Value::as_array) {
for p in list {
let spec = pad_from_json(p, back)?;
let i = insert_at(&doc.root, &["pad"], &["embedded_fonts", "model", "zone", "group"]);
kids(&mut doc.root).insert(i, node(&pad_text(&spec))?);
changes.push(format!("added pad {}", spec.number));
}
}
// renumber: {"map": {"1": "A1"}} or {"sequential": true, "start": 1}
if let Some(r) = args.get("renumber") {
let mut n = r.get("start").and_then(Value::as_u64).unwrap_or(1);
let map = r.get("map").and_then(Value::as_object).cloned().unwrap_or_default();
let seq = r.get("sequential").and_then(Value::as_bool).unwrap_or(false);
if map.is_empty() && !seq {
return Err("renumber needs map {old: new} or sequential:true".into());
}
let mut count = 0;
for c in kids(&mut doc.root).iter_mut().filter(|c| c.head() == Some("pad")) {
let old = c.arg(0).unwrap_or_default();
if old.is_empty() && seq {
continue; // unnumbered mechanical pads keep no number
}
let new = if seq {
let v = n.to_string();
n += 1;
Some(v)
} else {
map.get(&old).map(|v| v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()))
};
if let Some(v) = new {
c.set_arg(0, Node::quoted(&v));
count += 1;
}
}
changes.push(format!("renumbered {count} pad(s)"));
}
// graphics: {layer, mode: append|replace|delete, shapes: [...], width?}
if let Some(gs) = args.get("graphics") {
let ops: Vec<&Value> = match gs {
Value::Array(a) => a.iter().collect(),
v => vec![v],
};
for g in ops {
let layer = g.get("layer").and_then(Value::as_str).ok_or("graphics needs a layer (F.SilkS, F.Fab, F.CrtYd, ...)")?;
let mode = g.get("mode").and_then(Value::as_str).unwrap_or("append");
if !["append", "replace", "delete"].contains(&mode) {
return Err("graphics mode is append, replace or delete".into());
}
if mode != "append" {
let before = doc.root.children().len();
kids(&mut doc.root).retain(|c| !(c.head().map(|h| GRAPHIC_HEADS.contains(&h)).unwrap_or(false) && c.child("layer").and_then(|l| l.arg(0)).as_deref() == Some(layer)));
changes.push(format!("removed {} graphic(s) on {layer}", before - doc.root.children().len()));
}
if mode != "delete" {
let default_w = if layer.ends_with("CrtYd") { 0.05 } else if layer.ends_with("SilkS") || layer.ends_with("Silkscreen") { 0.12 } else { 0.1 };
let shapes = g.get("shapes").and_then(Value::as_array).ok_or("graphics needs shapes")?;
for s in shapes {
let t = graphic_text(s, layer, g.get("width").and_then(Value::as_f64).unwrap_or(default_w))?;
let i = insert_at(&doc.root, GRAPHIC_HEADS, &["fp_text", "fp_text_box", "pad", "embedded_fonts", "model"]);
kids(&mut doc.root).insert(i, node(&t)?);
}
changes.push(format!("added {} shape(s) on {layer}", shapes.len()));
}
}
}
// metadata: {description, tags, attributes: {type, boardOnly, excludeFromPos, excludeFromBom, dnp} | [..]}
if let Some(m) = args.get("metadata") {
if let Some(d) = m.get("description").and_then(Value::as_str) {
set_child(&mut doc.root, "descr", &format!("(descr {})", quote(d)), &["layer", "uuid", "at"])?;
// KiCad 8+ also keeps the description as a property.
if let Some(p) = kids(&mut doc.root).iter_mut().find(|c| c.head() == Some("property") && c.arg(0).as_deref() == Some("Description")) {
p.set_arg(1, Node::quoted(d));
}
changes.push("description".into());
}
if let Some(t) = m.get("tags") {
let t = match t {
Value::Array(a) => a.iter().filter_map(Value::as_str).collect::<Vec<_>>().join(" "),
Value::String(s) => s.clone(),
_ => String::new(),
};
set_child(&mut doc.root, "tags", &format!("(tags {})", quote(&t)), &["descr", "layer"])?;
changes.push("tags".into());
}
if let Some(a) = m.get("attributes") {
let atoms: Vec<String> = match a {
Value::Array(list) => list.iter().filter_map(Value::as_str).map(str::to_string).collect(),
Value::Object(o) => {
let mut v = Vec::new();
let ty = o.get("type").and_then(Value::as_str).unwrap_or("smd");
if ty != "unspecified" {
v.push(ty.to_string());
}
for (k, atom) in [("boardOnly", "board_only"), ("excludeFromPos", "exclude_from_pos_files"), ("excludeFromBom", "exclude_from_bom"), ("allowMissingCourtyard", "allow_missing_courtyard"), ("dnp", "dnp")] {
if o.get(k).and_then(Value::as_bool).unwrap_or(false) {
v.push(atom.into());
}
}
v
}
_ => return Err("attributes is a list of KiCad attr atoms or {type, boardOnly, excludeFromPos, excludeFromBom, dnp}".into()),
};
const OK: &[&str] = &["smd", "through_hole", "board_only", "exclude_from_pos_files", "exclude_from_bom", "allow_missing_courtyard", "dnp", "allow_soldermask_bridges"];
if let Some(bad) = atoms.iter().find(|a| !OK.contains(&a.as_str())) {
return Err(format!("unknown attribute {bad}; known: {OK:?}"));
}
if atoms.is_empty() {
kids(&mut doc.root).retain(|c| c.head() != Some("attr"));
} else {
set_child(&mut doc.root, "attr", &format!("(attr {})", atoms.join(" ")), &["property"])?;
}
changes.push(format!("attributes [{}]", atoms.join(" ")));
}
}
// models: [{path, offset, scale, rotate}] with mode replace (default) | append | delete
if let Some(ms) = args.get("models") {
let (mode, list): (&str, Vec<Value>) = match ms {
Value::Array(a) => ("replace", a.clone()),
Value::Object(o) => (o.get("mode").and_then(Value::as_str).unwrap_or("replace"), o.get("list").and_then(Value::as_array).cloned().unwrap_or_default()),
_ => return Err("models is a list of {path, offset, scale, rotate} or {mode, list}".into()),
};
if !["replace", "append", "delete"].contains(&mode) {
return Err("models mode is replace, append or delete".into());
}
if mode != "append" {
kids(&mut doc.root).retain(|c| c.head() != Some("model"));
changes.push("removed existing 3D models".into());
}
if mode != "delete" {
for m in &list {
let t = model_text(m)?;
let i = doc.root.children().len();
kids(&mut doc.root).insert(i, node(&t)?);
}
changes.push(format!("added {} 3D model(s)", list.len()));
}
}
if changes.is_empty() {
return Err("nothing to do: pass pads, addPads, renumber, graphics, metadata or models".into());
}
let out = doc.to_text();
crate::lint::check_footprint_structure(&out)?;
Document::parse(&out)?;
Ok((out, changes))
}
/// Read a pad node back into a spec.
fn pad_from_node(n: &Node) -> Result<PadSpec, String> {
let at = n.child("at").map(nums).unwrap_or_default();
let size = n.child("size").map(nums).unwrap_or_default();
let drill = n.child("drill").map(nums).filter(|d| !d.is_empty()).map(|d| (d[0], *d.get(1).unwrap_or(&d[0])));
Ok(PadSpec {
number: n.arg(0).unwrap_or_default(),
kind: n.arg(1).unwrap_or_else(|| "smd".into()),
shape: n.arg(2).unwrap_or_else(|| "rect".into()),
at: (*at.first().unwrap_or(&0.0), *at.get(1).unwrap_or(&0.0)),
rotation: *at.get(2).unwrap_or(&0.0),
size: (*size.first().unwrap_or(&1.0), *size.get(1).unwrap_or(size.first().unwrap_or(&1.0))),
drill,
layers: n.child("layers").map(|l| l.children().iter().skip(1).filter_map(Node::value).collect()).unwrap_or_default(),
rratio: n.child("roundrect_rratio").and_then(|r| r.num(0)),
})
}
fn merge_pad(cur: &PadSpec, set: &Value, back: bool) -> Result<PadSpec, String> {
let mut j = json!({
"number": cur.number, "type": cur.kind, "shape": cur.shape, "at": [cur.at.0, cur.at.1], "rotation": cur.rotation,
"size": [cur.size.0, cur.size.1], "layers": cur.layers,
});
if let Some(d) = cur.drill {
j["drill"] = json!([d.0, d.1]);
}
if let Some(r) = cur.rratio {
j["roundrectRatio"] = json!(r);
}
for k in ["type", "shape", "at", "position", "rotation", "size", "drill", "layers", "roundrectRatio"] {
if let Some(v) = set.get(k) {
j[if k == "position" { "at" } else { k }] = v.clone();
}
}
if set.get("type").and_then(Value::as_str) == Some("smd") && set.get("drill").is_none() {
j.as_object_mut().unwrap().remove("drill");
if set.get("layers").is_none() {
j.as_object_mut().unwrap().remove("layers");
}
}
pad_from_json(&j, back)
}
/// Copy children of `old` that `pad_text` does not write (net, pinfunction, pintype,
/// thermal settings, clearances...) and the original uuid into `new`.
fn carry_unmodelled(old: &Node, new: &mut Node) {
const MODELLED: &[&str] = &["at", "size", "drill", "layers", "roundrect_rratio", "remove_unused_layers", "uuid"];
let keep: Vec<Node> = old.children().iter().skip(4).filter(|c| c.is_list() && !c.head().map(|h| MODELLED.contains(&h)).unwrap_or(false)).cloned().collect();
let old_uuid = old.child("uuid").cloned();
if let Kind::List { children, .. } = &mut new.kind {
if let Some(u) = old_uuid {
if let Some(slot) = children.iter_mut().find(|c| c.head() == Some("uuid")) {
let lead = slot.lead.clone();
*slot = u;
slot.lead = lead;
}
}
let at = children.iter().position(|c| c.head() == Some("uuid")).unwrap_or(children.len());
for (k, c) in keep.into_iter().enumerate() {
children.insert(at + k, c);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn soic() -> Value {
let mut pads = Vec::new();
for i in 0..4 {
pads.push(json!({"number": i + 1, "size": [1.5, 0.6], "at": [-2.7, -1.905 + 1.27 * i as f64]}));
pads.push(json!({"number": 8 - i, "size": [1.5, 0.6], "at": [2.7, -1.905 + 1.27 * i as f64]}));
}
json!({"pads": pads, "bodySize": [3.9, 4.9], "description": "SOIC-8 test", "tags": ["soic", "so8"], "model": {"path": "${KICAD10_3DMODEL_DIR}/Package_SO.3dshapes/SOIC-8.step"}})
}
#[test]
fn create_writes_a_parseable_kicad10_footprint_with_courtyard() {
let (text, summary) = create("SOIC8_Test", &soic()).unwrap();
assert!(text.starts_with("(footprint \"SOIC8_Test\"\n\t(version 20260206)"));
crate::lint::check_footprint_structure(&text).unwrap();
let lint = crate::lint::parse_kicad_mod(&text);
assert_eq!(lint["padCount"], 8);
assert_eq!(lint["fileVersion"], 20260206);
let i = info(&text).unwrap();
assert_eq!(i["attributes"], json!(["smd"]));
assert_eq!(i["pads"][0]["shape"], "roundrect");
assert_eq!(i["pads"][0]["roundrectRatio"], 0.25);
// courtyard: pads reach x 3.45 (silk box 3.71 plus stroke), body y 2.45 plus 0.25 margin
let c = &summary["courtyard"];
assert_eq!(c["max"][0], 3.83);
assert_eq!(c["max"][1], 2.7);
assert_eq!(c["min"][0], -4.21, "the pin-1 dot sits left of the silk box");
assert_eq!(i["models"][0]["scale"], json!([1.0, 1.0, 1.0]));
assert!(i["layers"].as_array().unwrap().iter().any(|l| l == "F.CrtYd"));
assert!(i["layers"].as_array().unwrap().iter().any(|l| l == "F.SilkS"));
assert_eq!(i["tags"], "soic so8");
// the generated silkscreen never lands on a pad
let board = format!("(kicad_pcb (version 20260206) (layers (0 \"F.Cu\" signal) (2 \"B.Cu\" signal)) {})", text.replacen("(footprint \"SOIC8_Test\"", "(footprint \"L:SOIC8_Test\" (at 10 10) (property \"Reference\" \"U1\")", 1));
let g = crate::dfm::Geometry::from_text(&board).unwrap();
assert_eq!(g.pads.len(), 8);
assert!(g.silk_on_pads().is_empty(), "{:?}", g.silk_on_pads());
}
#[test]
fn create_rejects_bad_pads() {
assert!(create("X", &json!({"pads": []})).is_err());
assert!(create("X", &json!({"pads": [{"number": 1, "type": "thru_hole", "size": [1, 1]}]})).unwrap_err().contains("needs a drill"));
assert!(create("X", &json!({"pads": [{"number": 1, "type": "thru_hole", "size": [1, 1], "drill": 1.2}]})).unwrap_err().contains("smaller"));
assert!(create("X", &json!({"pads": [{"number": 1, "shape": "hex", "size": [1, 1]}]})).is_err());
assert!(create("a/b", &soic()).is_err());
let (tht, _) = create("Pin", &json!({"pads": [{"number": 1, "type": "thru_hole", "shape": "rect", "size": 1.7, "drill": 1.0}, {"number": 2, "type": "thru_hole", "size": 1.7, "drill": 1.0, "at": [2.54, 0]}, {"type": "np_thru_hole", "size": 3.2, "drill": 3.2, "at": [6, 0]}]})).unwrap();
let i = info(&tht).unwrap();
assert_eq!(i["attributes"], json!(["through_hole"]));
assert_eq!(i["pads"][1]["layers"], json!(["*.Cu", "*.Mask"]));
assert_eq!(i["pads"][2]["number"], "");
}
#[test]
fn edit_pads_graphics_metadata_models_and_keeps_the_rest() {
let (text, _) = create("SOIC8_Test", &soic()).unwrap();
// a pad with a child the bridge does not model must keep it
let text = text.replacen("(layers \"F.Cu\" \"F.Mask\" \"F.Paste\")\n\t\t(roundrect_rratio 0.25)", "(layers \"F.Cu\" \"F.Mask\" \"F.Paste\")\n\t\t(roundrect_rratio 0.25)\n\t\t(solder_mask_margin 0.05)", 1);
let pad1_uuid = info(&text).unwrap();
let _ = pad1_uuid;
let args = json!({
"pads": [{"number": "1", "set": {"size": [1.6, 0.65], "shape": "rect"}}, {"number": 8, "delete": true}],
"addPads": [{"number": "EP", "size": [2.2, 3.0]}],
"graphics": [{"layer": "F.SilkS", "mode": "replace", "shapes": [{"type": "line", "start": [-1, -3], "end": [1, -3]}, {"type": "circle", "center": [-3.5, -2], "radius": 0.1, "fill": true}]},
{"layer": "F.Fab", "shapes": [{"type": "poly", "points": [[0, 0], [1, 0], [0, 1]]}, {"type": "arc", "start": [0, 0], "mid": [0.5, 0.5], "end": [1, 0]}]}],
"metadata": {"description": "edited", "tags": "a b", "attributes": {"type": "smd", "excludeFromBom": true}},
"models": {"mode": "replace", "list": [{"path": "C:/m/x.step", "offset": [0, 0, 0.1], "rotate": {"z": 90}}]}
});
let (out, changes) = edit(&text, &args).unwrap();
assert!(changes.len() >= 7, "{changes:?}");
let i = info(&out).unwrap();
assert_eq!(i["padCount"], 8, "8 - 1 + EP");
let p1 = i["pads"].as_array().unwrap().iter().find(|p| p["number"] == "1").unwrap();
assert_eq!(p1["size"], json!([1.6, 0.65]));
assert_eq!(p1["shape"], "rect");
assert!(out.contains("(solder_mask_margin 0.05)"), "unmodelled pad child kept");
assert_eq!(i["description"], "edited");
assert_eq!(i["attributes"], json!(["smd", "exclude_from_bom"]));
assert_eq!(i["graphics"]["F.SilkS line"], 1);
assert_eq!(i["graphics"]["F.SilkS circle"], 1);
assert!(i["graphics"].get("F.SilkS rect").is_none(), "replace removed the old silk box");
assert_eq!(i["graphics"]["F.Fab poly"], 1);
assert_eq!(i["models"].as_array().unwrap().len(), 1);
assert_eq!(i["models"][0]["rotate"], json!([0.0, 0.0, 90.0]));
// the courtyard, the reference and the value were not touched
let before = info(&text).unwrap();
assert_eq!(before["courtyard"], i["courtyard"]);
assert_eq!(before["properties"]["Reference"], i["properties"]["Reference"]);
// renumber
let (out2, _) = edit(&out, &json!({"renumber": {"map": {"EP": "9"}}})).unwrap();
assert!(info(&out2).unwrap()["pads"].as_array().unwrap().iter().any(|p| p["number"] == "9"));
let (out3, _) = edit(&out2, &json!({"renumber": {"sequential": true}})).unwrap();
let numbers: Vec<String> = info(&out3).unwrap()["pads"].as_array().unwrap().iter().map(|p| p["number"].as_str().unwrap().to_string()).collect();
assert_eq!(numbers, (1..=8).map(|n| n.to_string()).collect::<Vec<_>>());
// a no-op edit is refused, an unknown pad is named
assert!(edit(&text, &json!({})).is_err());
assert!(edit(&text, &json!({"pads": [{"number": "99", "delete": true}]})).unwrap_err().contains("99"));
// an untouched region is byte-identical
let head_end = text.find("(attr").unwrap();
assert_eq!(&out3[..30], &text[..30]);
assert!(out3.contains(&text[..head_end].lines().take(4).collect::<Vec<_>>().join("\n")));
}
#[test]
fn info_reads_a_stock_style_footprint() {
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../pcm/src/adom-sample-lib/footprints/Adom_Sample.pretty/AdomSample_SOIC-8_3.9x4.9mm_P1.27mm.kicad_mod");
let i = info(&std::fs::read_to_string(p).unwrap()).unwrap();
assert_eq!(i["padCount"], 8);
assert_eq!(i["attributes"], json!(["smd"]));
assert!(i["courtyard"].is_object());
assert_eq!(i["models"].as_array().unwrap().len(), 1);
assert!(info("(kicad_symbol_lib)").is_err());
}
#[test]
fn plate_vias_plates_drilled_pads_through_every_copper_layer() {
let fp = "(footprint \"QFN_vias\"\n\t(layer \"F.Cu\")\n\t(pad \"17\" smd rect (at 0 0) (size 3 3) (layers \"F.Cu\" \"F.Paste\" \"F.Mask\"))\n\t(pad \"17\" thru_hole circle (at 0.5 0.5) (size 0.6 0.6) (drill 0.3) (layers \"F.Cu\"))\n\t(pad \"17\" smd circle (at -0.5 0.5) (size 0.6 0.6) (drill 0.3) (layers \"F.Cu\" \"F.Mask\"))\n\t(pad \"1\" thru_hole circle (at 2 2) (size 1.6 1.6) (drill 0.8) (layers \"*.Cu\" \"*.Mask\"))\n\t(pad \"\" np_thru_hole circle (at 3 3) (size 3 3) (drill 3) (layers \"*.Cu\" \"*.Mask\"))\n\t(pad \"5\" np_thru_hole circle (at 4 4) (size 1 1) (drill 1) (layers \"*.Cu\" \"*.Mask\"))\n)\n";
let (t, fixed, holes, unplated) = plate_vias(fp, false).unwrap();
assert_eq!(holes, 5);
assert_eq!(fixed.len(), 2, "{fixed:?}");
assert_eq!(fixed[0]["was"], "thru_hole");
assert_eq!(fixed[1]["was"], "smd");
assert_eq!(unplated, vec!["5".to_string()]);
let info = info(&t).unwrap();
let vias: Vec<&Value> = info["pads"].as_array().unwrap().iter().filter(|p| p["drill"].is_array() && p["number"] == "17").collect();
assert!(vias.iter().all(|p| p["type"] == "thru_hole" && p["layers"] == json!(["*.Cu", "*.Mask"])), "{vias:?}");
let (_, fixed2, _, unplated2) = plate_vias(fp, true).unwrap();
assert_eq!((fixed2.len(), unplated2.len()), (3, 0));
let (_, again, _, _) = plate_vias(&t, false).unwrap();
assert!(again.is_empty(), "plating is idempotent");
}
#[test]
fn normalize_bodies_moves_a_far_body_onto_the_pads_and_leaves_a_close_one() {
// a 2-pad part centred at (1, 0) mm; its model's offset puts the body 50 mm away
let fp = "(footprint \"R_test\"\n\t(layer \"F.Cu\")\n\t(pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\"))\n\t(pad \"2\" smd rect (at 2 0) (size 1 1) (layers \"F.Cu\"))\n\t(model \"far.step\"\n\t\t(offset\n\t\t\t(xyz 51 0 0)\n\t\t)\n\t\t(scale\n\t\t\t(xyz 1 1 1)\n\t\t)\n\t\t(rotate\n\t\t\t(xyz 0 0 0)\n\t\t)\n\t)\n\t(model \"near.step\"\n\t\t(offset\n\t\t\t(xyz 1.5 0 0)\n\t\t)\n\t)\n)\n";
let b = crate::bodies::Bounds { min: [-1.0, -0.5, 0.0], max: [1.0, 0.5, 0.5] };
let mut resolve = |_: &str| -> Result<crate::bodies::Bounds, String> { Ok(b) };
let (t, moved, kept) = normalize_bodies(fp, 2.0, &mut resolve).unwrap();
assert_eq!(moved.len(), 1, "{moved:?}");
assert_eq!(moved[0]["path"], "far.step");
assert_eq!(moved[0]["offsetAfter"], json!([1.0, 0.0, 0.0]));
assert_eq!(moved[0]["shiftMm"], 50.0);
assert_eq!(kept[0]["path"], "near.step");
assert!(t.contains("(xyz 1 0 0)") && t.contains("(xyz 1.5 0 0)"), "{t}");
let (_, again, _) = normalize_bodies(&t, 2.0, &mut resolve).unwrap();
assert!(again.is_empty());
}
}