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 #313: kicad-lane: 1.0.68, BOM, JLCPCB assembly and order, land-pattern footprints, designators
e1c2dac
13d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881
//! Footprint blocks as KiCad text: place a library footprint on the board, move or rotate
//! one, flip it to the other side, give it fresh UUIDs, set its fields, attributes, lock
//! state, graphics and 3D models. Every function takes one `(footprint ...)` block and
//! returns the edited block; untouched bytes stay exactly as KiCad wrote them.
//!
//! KiCad's file conventions this follows (KiCad 7 to 10):
//! - children positions (`at`, `start`, `end`, `mid`, `center`, `xy`) are footprint-local
//! and unrotated; the angle in a pad's or text's `(at x y a)` is ABSOLUTE (the footprint's
//! orientation plus the item's own), so rotating a footprint adds the delta to it;
//! - zones inside a footprint carry absolute board coordinates;
//! - a flip (`FOOTPRINT::Flip`) keeps the anchor where it is, swaps every side-specific layer
//! (F.x and B.x, inner copper In1 and In(n-2)), negates every local y, and sets the
//! orientation to -a (top/bottom) or 180-a (left/right, KiCad's default); pad angles become
//! -a or 180-a, text angles 180-a or -a, and side-specific text toggles its mirror.
use serde_json::{json, Value};
use crate::sxedit::{self, n, q, Edits, Node};
pub type FResult<T> = Result<T, String>;
/// The footprint's `(at x y [rot])`.
pub fn pose(fp: &Node) -> (f64, f64, f64) {
let at = fp.kid("at");
(
at.and_then(|a| a.f64_at(1)).unwrap_or(0.0),
at.and_then(|a| a.f64_at(2)).unwrap_or(0.0),
at.and_then(|a| a.f64_at(3)).unwrap_or(0.0),
)
}
pub fn norm_deg(a: f64) -> f64 {
let mut r = a.rem_euclid(360.0);
if (r - 360.0).abs() < 1e-9 {
r = 0.0;
}
(r * 1e6).round() / 1e6
}
fn at_text(x: f64, y: f64, a: f64) -> String {
let a = norm_deg(a);
if a == 0.0 {
format!("(at {} {})", n(x), n(y))
} else {
format!("(at {} {} {})", n(x), n(y), n(a))
}
}
/// KiCad's `FlipLayer` for a board with `copper` copper layers.
pub fn flip_layer(name: &str, copper: usize) -> String {
if let Some(rest) = name.strip_prefix("F.") {
return format!("B.{rest}");
}
if let Some(rest) = name.strip_prefix("B.") {
return format!("F.{rest}");
}
if let Some(k) = name.strip_prefix("In").and_then(|r| r.strip_suffix(".Cu")).and_then(|k| k.parse::<usize>().ok()) {
let inner = copper.saturating_sub(2);
if k >= 1 && k <= inner {
return format!("In{}.Cu", inner + 1 - k);
}
}
name.to_string()
}
fn is_side_specific(layer: &str) -> bool {
layer.starts_with("F.") || layer.starts_with("B.")
}
/// The footprint's own layer, "F.Cu" or "B.Cu".
pub fn side(fp: &Node) -> String {
fp.value_of("layer").unwrap_or("F.Cu").to_string()
}
/// Every `(uuid "...")` in the block replaced by a fresh one.
pub fn fresh_uuids(block: &str) -> FResult<String> {
let root = sxedit::parse(block)?;
let mut e = Edits::new();
root.walk(&mut |nd| {
if matches!(nd.tag(), Some("uuid") | Some("tstamp")) {
if let Some(v) = nd.kids.get(1) {
e.replace_node(v, q(&crate::schematic::new_uuid()));
}
}
});
e.apply(block)
}
fn rotate_local(p: (f64, f64), deg: f64) -> (f64, f64) {
crate::placement::to_board((0.0, 0.0), deg, p)
}
/// Move and rotate a footprint block to `(x, y, rot)`: the anchor, every absolute child
/// angle by the rotation delta, and the absolute zone outlines with the anchor.
pub fn move_block(block: &str, x: f64, y: f64, rot: f64) -> FResult<String> {
let fp = sxedit::parse(block)?;
let (ox, oy, orot) = pose(&fp);
let delta = rot - orot;
let mut e = Edits::new();
match fp.kid("at") {
Some(at) => e.replace_node(at, at_text(x, y, rot)),
None => {
let after = fp.kid("layer").map(|l| l.end).unwrap_or(fp.kids[1].end);
e.insert(after, format!(" {}", at_text(x, y, rot)));
}
}
for k in fp.kids.iter().skip(1) {
match k.tag() {
Some("pad") | Some("property") | Some("fp_text") => {
if delta.abs() > 1e-9 {
if let Some(at) = k.kid("at") {
let a = at.f64_at(3).unwrap_or(0.0) + delta;
e.replace_node(at, at_text(at.f64_at(1).unwrap_or(0.0), at.f64_at(2).unwrap_or(0.0), a));
}
}
}
Some("zone") => {
k.walk(&mut |nd| {
if nd.tag() == Some("xy") {
let p = (nd.f64_at(1).unwrap_or(0.0) - ox, nd.f64_at(2).unwrap_or(0.0) - oy);
let r = rotate_local(p, delta);
e.replace_node(nd, format!("(xy {} {})", n(x + r.0), n(y + r.1)));
}
});
}
_ => {}
}
}
e.apply(block)
}
/// Put one of a footprint's fields (`Reference`, `Value`) at a board position (KiCad's frame) at an
/// absolute angle, on `layer`, with a `size` mm font and its stroke, and shown. A field's `at` is
/// in the footprint's own (turned) frame and its angle absolute, as KiCad writes them.
pub fn set_field_pose(block: &str, field: &str, board: (f64, f64), angle: f64, layer: &str, size: f64, thickness: f64) -> FResult<String> {
let fp = sxedit::parse(block)?;
let (ox, oy, orot) = pose(&fp);
let prop = fp.kids_tagged("property").find(|p| p.atom(1) == Some(field)).ok_or_else(|| format!("the footprint has no {field} field"))?;
// board = origin + R(rot) local, with KiCad's y-down turn (x' = x cos + y sin, y' = -x sin + y cos)
let (s, c) = orot.to_radians().sin_cos();
let (dx, dy) = (board.0 - ox, board.1 - oy);
let local = (dx * c - dy * s, dx * s + dy * c);
let mut e = Edits::new();
match prop.kid("at") {
Some(at) => e.replace_node(at, at_text(local.0, local.1, angle)),
None => e.insert(prop.kids[2].end, format!(" {}", at_text(local.0, local.1, angle))),
}
match prop.kid("layer") {
Some(l) => e.replace_node(l, format!("(layer {})", sxedit::q(layer))),
None => e.insert(prop.kids[2].end, format!(" (layer {})", sxedit::q(layer))),
}
if let Some(h) = prop.kid("hide") {
e.remove_node(block, h);
}
let font = prop.kid("effects").and_then(|ef| ef.kid("font"));
match font {
Some(f) => {
match f.kid("size") {
Some(sz) => e.replace_node(sz, format!("(size {} {})", n(size), n(size))),
None => e.insert(f.kids[0].end, format!(" (size {} {})", n(size), n(size))),
}
match f.kid("thickness") {
Some(t) => e.replace_node(t, format!("(thickness {})", n(thickness))),
None => e.insert(f.kids[0].end, format!(" (thickness {})", n(thickness))),
}
}
None => match prop.kid("effects") {
Some(ef) => e.insert(ef.kids[0].end, format!(" (font (size {} {}) (thickness {}))", n(size), n(size), n(thickness))),
None => e.insert(prop.end - 1, format!(" (effects (font (size {} {}) (thickness {})))", n(size), n(size), n(thickness))),
},
}
// an older (effects ... hide) word
if let Some(ef) = prop.kid("effects") {
if let Some(w) = ef.kids.iter().skip(1).find(|k| !k.quoted && k.atom.as_deref() == Some("hide")) {
e.remove_node(block, w);
}
}
e.apply(block)
}
/// Child kinds a client-side flip handles; anything else refuses the flip.
pub const FLIP_CHILDREN: &[&str] = &[
"layer", "uuid", "tstamp", "at", "descr", "tags", "property", "path", "sheetname", "sheetfile", "attr", "units", "fp_text", "fp_line", "fp_rect", "fp_circle", "fp_arc",
"fp_poly", "fp_curve", "pad", "model", "zone", "locked", "embedded_fonts", "embedded_files", "duplicate_pad_numbers_are_jumpers", "jumper_pad_groups", "net_tie_pad_groups",
"solder_mask_margin", "solder_paste_margin", "solder_paste_margin_ratio", "solder_paste_ratio", "clearance", "zone_connect", "private_layers", "component_classes", "version",
"generator", "generator_version", "placed", "autoplace_cost90", "autoplace_cost180", "thermal_width", "thermal_gap", "net_tie_pad_groups", "sheetname",
];
fn negate_points(nd: &Node, e: &mut Edits) {
nd.walk(&mut |p| {
if matches!(p.tag(), Some("xy") | Some("start") | Some("end") | Some("mid") | Some("center")) && p.kids.len() >= 3 {
if let (Some(x), Some(y)) = (p.f64_at(1), p.f64_at(2)) {
let tag = p.tag().unwrap_or("xy");
e.replace_node(p, format!("({tag} {} {})", n(x), n(-y)));
}
}
});
}
fn flip_layer_kid(nd: &Node, copper: usize, e: &mut Edits) {
if let Some(l) = nd.kid("layer") {
if let Some(v) = l.kids.get(1) {
if let Some(name) = v.atom.as_deref() {
e.replace_node(v, q(&flip_layer(name, copper)));
}
}
}
}
fn toggle_mirror(text_item: &Node, e: &mut Edits) {
let Some(effects) = text_item.kid("effects") else { return };
match effects.kid("justify") {
Some(j) => {
let words: Vec<&str> = j.kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect();
let mut kept: Vec<&str> = words.iter().copied().filter(|w| *w != "mirror").collect();
if !words.contains(&"mirror") {
kept.push("mirror");
}
if kept.is_empty() {
e.replace(j.start, j.end, "");
} else {
e.replace_node(j, format!("(justify {})", kept.join(" ")));
}
}
None => e.insert(effects.end - 1, " (justify mirror)"),
}
}
/// Flip a footprint to the other side, about its own anchor, the way KiCad's
/// `FOOTPRINT::Flip` does. `left_right` is KiCad's default flip direction.
pub fn flip_block(block: &str, left_right: bool, copper: usize) -> FResult<String> {
let fp = sxedit::parse(block)?;
if fp.tag() != Some("footprint") {
return Err("not a footprint block".into());
}
let (x, y, rot) = pose(&fp);
let mut e = Edits::new();
let new_rot = if left_right { 180.0 - rot } else { -rot };
match fp.kid("at") {
Some(at) => e.replace_node(at, at_text(x, y, new_rot)),
None => return Err("footprint has no (at ...)".into()),
}
flip_layer_kid(&fp, copper, &mut e);
for k in fp.kids.iter().skip(2) {
let Some(tag) = k.tag() else { continue };
match tag {
"property" | "fp_text" => {
if let Some(at) = k.kid("at") {
let a = at.f64_at(3).unwrap_or(0.0);
let na = if left_right { -a } else { 180.0 - a };
e.replace_node(at, at_text(at.f64_at(1).unwrap_or(0.0), -at.f64_at(2).unwrap_or(0.0), na));
}
let layer = k.value_of("layer").unwrap_or("");
flip_layer_kid(k, copper, &mut e);
if is_side_specific(layer) {
toggle_mirror(k, &mut e);
}
}
"fp_line" | "fp_rect" | "fp_circle" | "fp_arc" | "fp_poly" | "fp_curve" => {
negate_points(k, &mut e);
flip_layer_kid(k, copper, &mut e);
}
"pad" => flip_pad(k, left_right, copper, &mut e)?,
"zone" => {
// absolute coordinates: mirror about the anchor; fills are dropped (KiCad refills)
k.walk(&mut |nd| {
if nd.tag() == Some("filled_polygon") {
e.replace(nd.start, nd.end, "");
}
});
if let Some(poly) = k.kid("polygon") {
poly.walk(&mut |p| {
if p.tag() == Some("xy") {
let (px, py) = (p.f64_at(1).unwrap_or(0.0), p.f64_at(2).unwrap_or(0.0));
let (nx, ny) = if left_right { (2.0 * x - px, py) } else { (px, 2.0 * y - py) };
e.replace_node(p, format!("(xy {} {})", n(nx), n(ny)));
}
});
}
if let Some(ls) = k.kid("layers") {
let names: Vec<String> = ls.kids.iter().skip(1).filter_map(|a| a.atom.as_deref()).map(|l| q(&flip_layer(l, copper))).collect();
e.replace_node(ls, format!("(layers {})", names.join(" ")));
}
flip_layer_kid(k, copper, &mut e);
}
"model" => {
let nonzero = |t: &str, i: usize| k.kid(t).and_then(|o| o.kid("xyz")).and_then(|v| v.f64_at(i)).map(|v| v.abs() > 1e-9).unwrap_or(false);
if nonzero("offset", 2) || nonzero("rotate", 1) || nonzero("rotate", 2) {
return Err(format!("the 3D model {} has a y offset or an x/y rotation that a flip would have to mirror; the bridge does not reproduce KiCad's model transform", k.atom(1).unwrap_or("")));
}
}
t if FLIP_CHILDREN.contains(&t) => {}
other => return Err(format!("footprint carries a ({other} ...) child the bridge cannot flip")),
}
}
e.apply(block)
}
fn flip_pad(pad: &Node, left_right: bool, copper: usize, e: &mut Edits) -> FResult<()> {
if let Some(at) = pad.kid("at") {
let a = at.f64_at(3).unwrap_or(0.0);
let na = if left_right { 180.0 - a } else { -a };
e.replace_node(at, at_text(at.f64_at(1).unwrap_or(0.0), -at.f64_at(2).unwrap_or(0.0), na));
}
if let Some(ls) = pad.kid("layers") {
let names: Vec<String> = ls.kids.iter().skip(1).filter_map(|a| a.atom.as_deref()).map(|l| q(&flip_layer(l, copper))).collect();
e.replace_node(ls, format!("(layers {})", names.join(" ")));
}
// pad-local geometry: drill offset, trapezoid delta, custom primitives
if let Some(drill) = pad.kid("drill") {
if let Some(off) = drill.kid("offset") {
if let (Some(ox), Some(oy)) = (off.f64_at(1), off.f64_at(2)) {
e.replace_node(off, format!("(offset {} {})", n(ox), n(-oy)));
}
}
}
if let Some(d) = pad.kid("rect_delta") {
if let (Some(dx), Some(dy)) = (d.f64_at(1), d.f64_at(2)) {
e.replace_node(d, format!("(rect_delta {} {})", n(dx), n(-dy)));
}
}
if let Some(prims) = pad.kid("primitives") {
negate_points(prims, e);
}
if let Some(ch) = pad.kid("chamfer") {
let words: Vec<String> = ch
.kids
.iter()
.skip(1)
.filter_map(|k| k.atom.as_deref())
.map(|w| match w {
"top_left" => "bottom_left",
"bottom_left" => "top_left",
"top_right" => "bottom_right",
"bottom_right" => "top_right",
o => o,
}.to_string())
.collect();
e.replace_node(ch, format!("(chamfer {})", words.join(" ")));
}
if pad.kid("padstack").is_some() {
return Err(format!("pad {} has a per-layer padstack the bridge does not flip", pad.atom(1).unwrap_or("")));
}
Ok(())
}
/// Set (or add) a property's value. A new property is hidden on the Fab layer of the
/// footprint's side.
pub fn set_property(block: &str, name: &str, value: &str) -> FResult<String> {
let fp = sxedit::parse(block)?;
let mut e = Edits::new();
match fp.kids_tagged("property").find(|p| p.atom(1) == Some(name)) {
Some(p) => match p.kids.get(2) {
Some(v) => e.replace_node(v, q(value)),
None => e.insert(p.kids[1].end, format!(" {}", q(value))),
},
None => {
let fab = if side(&fp) == "B.Cu" { "B.Fab" } else { "F.Fab" };
let anchor = fp.kids_tagged("property").last().map(|p| p.end).or_else(|| fp.kid("at").map(|a| a.end)).unwrap_or(fp.kids[1].end);
let rot = pose(&fp).2;
e.insert(
anchor,
format!(
"\n\t\t(property {} {}\n\t\t\t{}\n\t\t\t(layer {})\n\t\t\t(hide yes)\n\t\t\t(uuid {})\n\t\t\t(effects (font (size 1 1) (thickness 0.15)))\n\t\t)",
q(name),
q(value),
at_text(0.0, 0.0, rot),
q(fab),
q(&crate::schematic::new_uuid())
),
);
}
}
e.apply(block)
}
/// Remove a user property (the four mandatory fields are refused).
pub fn remove_property(block: &str, name: &str) -> FResult<String> {
if ["Reference", "Value", "Footprint", "Datasheet", "Description"].contains(&name) {
return Err(format!("{name} is a mandatory field and cannot be removed"));
}
let fp = sxedit::parse(block)?;
let Some(p) = fp.kids_tagged("property").find(|p| p.atom(1) == Some(name)) else {
return Err(format!("no property named {name}"));
};
let mut e = Edits::new();
e.remove_node(block, p);
e.apply(block)
}
/// Every property (name, value).
pub fn properties(fp: &Node) -> Vec<(String, String)> {
fp.kids_tagged("property").filter_map(|p| Some((p.atom(1)?.to_string(), p.atom(2).unwrap_or("").to_string()))).collect()
}
/// The `(attr ...)` words that are flags (everything but the mounting type).
pub const ATTR_FLAGS: &[(&str, &str)] = &[
("excludeFromPosFiles", "exclude_from_pos_files"),
("excludeFromBom", "exclude_from_bom"),
("dnp", "dnp"),
("boardOnly", "board_only"),
("allowMissingCourtyard", "allow_missing_courtyard"),
("allowSoldermaskBridges", "allow_soldermask_bridges"),
];
/// The footprint's attributes as JSON: type (smd, through_hole, unspecified) and each flag.
pub fn attrs(fp: &Node) -> Value {
let words: Vec<&str> = fp.kid("attr").map(|a| a.kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect()).unwrap_or_default();
let mut v = json!({"type": words.iter().find(|w| **w == "smd" || **w == "through_hole").copied().unwrap_or("unspecified")});
for (k, w) in ATTR_FLAGS {
v[*k] = json!(words.contains(w));
}
v
}
/// Set attribute flags (`changes` maps the JSON names of `ATTR_FLAGS` to bools; `type` too).
pub fn set_attrs(block: &str, changes: &serde_json::Map<String, Value>) -> FResult<String> {
let fp = sxedit::parse(block)?;
let cur = attrs(&fp);
let ty = match changes.get("type") {
Some(Value::String(t)) if matches!(t.as_str(), "smd" | "through_hole" | "unspecified") => t.clone(),
Some(_) => return Err("type must be smd, through_hole or unspecified".into()),
None => cur["type"].as_str().unwrap_or("unspecified").to_string(),
};
let mut words: Vec<&str> = Vec::new();
if ty != "unspecified" {
words.push(if ty == "smd" { "smd" } else { "through_hole" });
}
for (k, w) in ATTR_FLAGS {
let on = match changes.get(*k) {
Some(Value::Bool(b)) => *b,
Some(_) => return Err(format!("{k} must be true or false")),
None => cur[*k].as_bool().unwrap_or(false),
};
if on {
words.push(w);
}
}
let text = if words.is_empty() { String::new() } else { format!("(attr {})", words.join(" ")) };
let mut e = Edits::new();
match fp.kid("attr") {
Some(a) if text.is_empty() => e.remove_node(block, a),
Some(a) => e.replace_node(a, text),
None if text.is_empty() => {}
None => {
let anchor = fp.kid("sheetfile").or_else(|| fp.kids_tagged("property").last()).or_else(|| fp.kid("at")).map(|n| n.end).unwrap_or(fp.kids[1].end);
e.insert(anchor, format!("\n\t\t{text}"));
}
}
e.apply(block)
}
/// Lock or unlock: `(locked yes)` after the layer, as KiCad 9 and 10 write it (the bare
/// `locked` word of older files is removed either way).
pub fn set_locked(block: &str, locked: bool) -> FResult<String> {
let fp = sxedit::parse(block)?;
let mut e = Edits::new();
for k in fp.kids.iter().skip(2) {
if k.tag() == Some("locked") || (!k.quoted && k.atom.as_deref() == Some("locked")) {
e.replace(k.start, k.end, "");
}
}
if locked {
let after = fp.kid("layer").map(|l| l.end).unwrap_or(fp.kids[1].end);
e.insert(after, " (locked yes)");
}
e.apply(block)
}
pub fn is_locked(fp: &Node) -> bool {
fp.kid("locked").map(|l| l.atom(1) != Some("no")).unwrap_or(false) || fp.has_word("locked")
}
/// Pad nets removed (a duplicate is a new part; it must not short its source's nets).
pub fn strip_nets(block: &str) -> FResult<String> {
let fp = sxedit::parse(block)?;
let mut e = Edits::new();
for pad in fp.kids_tagged("pad") {
if let Some(net) = pad.kid("net") {
e.replace(net.start, net.end, "");
}
}
e.apply(block)
}
/// The symbol link removed (`path`, `sheetname`, `sheetfile`): the copy is not in the schematic.
pub fn strip_symbol_link(block: &str) -> FResult<String> {
let fp = sxedit::parse(block)?;
let mut e = Edits::new();
for t in ["path", "sheetname", "sheetfile"] {
if let Some(k) = fp.kid(t) {
e.remove_node(block, k);
}
}
e.apply(block)
}
/// A library footprint (`.kicad_mod` text) as a board footprint at `(x, y, rot)` on `side`,
/// with the given reference (and value when given), fresh UUIDs, named `lib_id`.
pub fn place_library(lib_text: &str, lib_id: &str, x: f64, y: f64, rot: f64, back: bool, reference: &str, value: Option<&str>, copper: usize) -> FResult<String> {
let root = sxedit::parse(lib_text)?;
if !matches!(root.tag(), Some("footprint") | Some("module")) {
return Err("the library file is not a (footprint ...)".into());
}
let mut e = Edits::new();
e.replace_node(&root.kids[0], "footprint");
match root.kids.get(1) {
Some(name) if name.atom.is_some() => e.replace_node(name, q(lib_id)),
_ => return Err("the library footprint has no name".into()),
}
for t in ["version", "generator", "generator_version", "at", "uuid", "tstamp", "locked", "placed"] {
for k in root.kids_tagged(t) {
e.remove_node(lib_text, k);
}
}
let after = root.kid("layer").map(|l| l.end).unwrap_or(root.kids[1].end);
if root.kid("layer").is_none() {
e.insert(after, " (layer \"F.Cu\")");
}
e.insert(after, format!("\n\t\t(uuid {})\n\t\t(at 0 0)", q(&crate::schematic::new_uuid())));
let text = e.apply(lib_text)?;
let text = fresh_uuids(&text)?;
let mut text = set_property(&text, "Reference", reference)?;
if let Some(v) = value {
text = set_property(&text, "Value", v)?;
}
if back {
text = flip_block(&text, true, copper)?;
}
move_block(&text, x, y, rot)
}
/// The board's copper layer count from its `(layers ...)` table.
pub fn copper_count(root: &Node) -> usize {
root.kid("layers").map(|ls| ls.kids.iter().skip(1).filter(|l| l.atom(1).map(|n| n.ends_with(".Cu")).unwrap_or(false)).count()).unwrap_or(2).max(2)
}
/// Graphics of a placed footprint: every fp_line/rect/circle/arc/poly/curve and fp_text,
/// with its layer, kind, local points, width and uuid.
pub fn graphics(fp: &Node) -> Vec<Value> {
let mut out = Vec::new();
for k in fp.kids.iter().skip(2) {
let Some(tag) = k.tag() else { continue };
let kind = match tag {
"fp_line" => "line",
"fp_rect" => "rect",
"fp_circle" => "circle",
"fp_arc" => "arc",
"fp_poly" => "polygon",
"fp_curve" => "bezier",
"fp_text" => "text",
_ => continue,
};
let pt = |t: &str| k.kid(t).and_then(|p| Some(json!([p.f64_at(1)?, p.f64_at(2)?])));
let mut row = json!({"uuid": k.uuid(), "kind": kind, "layer": k.value_of("layer")});
for t in ["start", "mid", "end", "center"] {
if let Some(p) = pt(t) {
row[t] = p;
}
}
if let Some(pts) = k.kid("pts") {
row["points"] = json!(pts.kids_tagged("xy").filter_map(|p| Some([p.f64_at(1)?, p.f64_at(2)?])).collect::<Vec<_>>());
}
if let Some(w) = k.kid("stroke").and_then(|s| s.kid("width")).and_then(|w| w.f64_at(1)).or_else(|| k.kid("width").and_then(|w| w.f64_at(1))) {
row["width"] = json!(w);
}
if let Some(f) = k.kid("fill") {
row["fill"] = json!(f.atom(1));
}
if tag == "fp_text" {
row["text"] = json!(k.atom(2));
row["textKind"] = json!(k.atom(1));
}
out.push(row);
}
out
}
/// Edit one footprint graphic by uuid: `layer`, `width`, `points` (polygon) or any of
/// `start`, `mid`, `end`, `center` as [x, y] in footprint-local mm, `fill`, or `delete:true`.
pub fn edit_graphic(block: &str, uuid: &str, change: &Value) -> FResult<String> {
let fp = sxedit::parse(block)?;
let Some(item) = fp.kids.iter().skip(2).find(|k| k.tag().map(|t| t.starts_with("fp_")).unwrap_or(false) && k.uuid() == Some(uuid)) else {
return Err(format!("no footprint graphic with uuid {uuid}"));
};
let mut e = Edits::new();
if change.get("delete") == Some(&json!(true)) {
e.remove_node(block, item);
return e.apply(block);
}
let pair = |v: &Value| -> FResult<(f64, f64)> {
let a = v.as_array().filter(|a| a.len() == 2).ok_or("a point is [x, y] in mm")?;
let (x, y) = (a[0].as_f64().ok_or("x must be a number")?, a[1].as_f64().ok_or("y must be a number")?);
if !x.is_finite() || !y.is_finite() {
return Err("points must be finite".into());
}
Ok((x, y))
};
let mut touched = false;
for t in ["start", "mid", "end", "center"] {
if let Some(v) = change.get(t) {
let (x, y) = pair(v)?;
match item.kid(t) {
Some(p) => e.replace_node(p, format!("({t} {} {})", n(x), n(y))),
None => return Err(format!("this {} has no {t} point", item.tag().unwrap_or(""))),
}
touched = true;
}
}
if let Some(v) = change.get("points") {
let pts = v.as_array().ok_or("points is a list of [x, y]")?;
if pts.len() < 2 {
return Err("points needs at least 2 vertices".into());
}
let Some(old) = item.kid("pts") else { return Err("only a polygon or bezier takes points".into()) };
let mut s = String::from("(pts");
for p in pts {
let (x, y) = pair(p)?;
s.push_str(&format!(" (xy {} {})", n(x), n(y)));
}
s.push(')');
e.replace_node(old, s);
touched = true;
}
if let Some(w) = change.get("width") {
let w = w.as_f64().filter(|w| w.is_finite() && *w >= 0.0 && *w < 100.0).ok_or("width is mm, 0 to 100")?;
match item.kid("stroke").and_then(|s| s.kid("width")) {
Some(old) => e.replace_node(old, format!("(width {})", n(w))),
None => match item.kid("width") {
Some(old) => e.replace_node(old, format!("(width {})", n(w))),
None => e.insert(item.end - 1, format!(" (stroke (width {}) (type solid))", n(w))),
},
}
touched = true;
}
if let Some(l) = change.get("layer") {
let l = l.as_str().ok_or("layer is a layer name")?;
match item.kid("layer").and_then(|k| k.kids.get(1)) {
Some(v) => e.replace_node(v, q(l)),
None => return Err("this graphic has no layer".into()),
}
touched = true;
}
if let Some(f) = change.get("fill") {
let f = match f {
Value::Bool(true) => "yes",
Value::Bool(false) => "no",
Value::String(s) if s == "yes" || s == "no" || s == "solid" || s == "none" => if s == "solid" { "yes" } else if s == "none" { "no" } else { s.as_str() },
_ => return Err("fill is true or false".into()),
};
match item.kid("fill") {
Some(old) => e.replace_node(old, format!("(fill {f})")),
None => e.insert(item.end - 1, format!(" (fill {f})")),
}
touched = true;
}
if !touched {
return Err("nothing to change: pass layer, width, points, start/mid/end/center, fill or delete:true".into());
}
e.apply(block)
}
/// A footprint's 3D model entries.
pub fn models(fp: &Node) -> Vec<Value> {
fp.kids_tagged("model")
.enumerate()
.map(|(i, m)| {
let v3 = |t: &str, d: f64| -> Value {
let x = m.kid(t).and_then(|o| o.kid("xyz"));
json!([x.and_then(|v| v.f64_at(1)).unwrap_or(d), x.and_then(|v| v.f64_at(2)).unwrap_or(d), x.and_then(|v| v.f64_at(3)).unwrap_or(d)])
};
let hidden = m.has_word("hide") || m.kid("hide").map(|h| h.atom(1) != Some("no")).unwrap_or(false);
json!({"index": i, "path": m.atom(1), "offset": v3("offset", 0.0), "scale": v3("scale", 1.0), "rotate": v3("rotate", 0.0), "visible": !hidden,
"opacity": m.kid("opacity").and_then(|o| o.f64_at(1))})
})
.collect()
}
fn xyz(v: Option<&Value>, d: [f64; 3], what: &str) -> FResult<[f64; 3]> {
match v {
None | Some(Value::Null) => Ok(d),
Some(Value::Array(a)) if a.len() == 3 => {
let mut o = [0.0; 3];
for (i, x) in a.iter().enumerate() {
o[i] = x.as_f64().filter(|f| f.is_finite()).ok_or(format!("{what} must be three finite numbers"))?;
}
Ok(o)
}
_ => Err(format!("{what} must be [x, y, z]")),
}
}
fn model_text(path: &str, offset: [f64; 3], scale: [f64; 3], rotate: [f64; 3], hidden: bool) -> String {
let v = |a: [f64; 3]| format!("(xyz {} {} {})", n(a[0]), n(a[1]), n(a[2]));
format!("(model {}{}\n\t\t\t(offset\n\t\t\t\t{}\n\t\t\t)\n\t\t\t(scale\n\t\t\t\t{}\n\t\t\t)\n\t\t\t(rotate\n\t\t\t\t{}\n\t\t\t)\n\t\t)", q(path), if hidden { " (hide yes)" } else { "" }, v(offset), v(scale), v(rotate))
}
/// Add, replace or remove 3D model entries. `ops` is a list of
/// `{action: add|replace|remove, index?, path?, offset?, scale?, rotate?, visible?}`.
pub fn edit_models(block: &str, ops: &[Value]) -> FResult<String> {
let mut text = block.to_string();
for op in ops {
let fp = sxedit::parse(&text)?;
let existing: Vec<&Node> = fp.kids_tagged("model").collect();
let action = op.get("action").and_then(Value::as_str).unwrap_or("");
let index = || -> FResult<usize> {
match op.get("index").and_then(Value::as_u64) {
Some(i) if (i as usize) < existing.len() => Ok(i as usize),
Some(i) => Err(format!("model index {i} out of range ({} models)", existing.len())),
None => match op.get("path").and_then(Value::as_str) {
Some(p) => existing.iter().position(|m| m.atom(1) == Some(p)).ok_or(format!("no model with path {p}")),
None if existing.len() == 1 => Ok(0),
None => Err("name the model with index or path".into()),
},
}
};
let mut e = Edits::new();
match action {
"add" => {
let path = op.get("path").and_then(Value::as_str).filter(|p| !p.is_empty()).ok_or("add needs path")?;
let t = model_text(path, xyz(op.get("offset"), [0.0; 3], "offset")?, xyz(op.get("scale"), [1.0; 3], "scale")?, xyz(op.get("rotate"), [0.0; 3], "rotate")?, op.get("visible") == Some(&json!(false)));
e.insert(fp.end - 1, format!("\t{t}\n\t"));
}
"replace" => {
let i = index()?;
let old = models(&fp)[i].clone();
let get = |k: &str, d: &Value| op.get(k).cloned().unwrap_or_else(|| d.clone());
let path = op.get("newPath").or_else(|| if op.get("index").is_some() { op.get("path") } else { None }).and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| old["path"].as_str().unwrap_or("").to_string());
let t = model_text(
&path,
xyz(Some(&get("offset", &old["offset"])), [0.0; 3], "offset")?,
xyz(Some(&get("scale", &old["scale"])), [1.0; 3], "scale")?,
xyz(Some(&get("rotate", &old["rotate"])), [0.0; 3], "rotate")?,
!get("visible", &old["visible"]).as_bool().unwrap_or(true),
);
e.replace_node(existing[i], t);
}
"remove" => {
let i = index()?;
e.remove_node(&text, existing[i]);
}
other => return Err(format!("action must be add, replace or remove, not '{other}'")),
}
text = e.apply(&text)?;
}
Ok(text)
}
#[cfg(test)]
mod field_pose_tests {
use super::*;
#[test]
fn a_field_lands_where_asked_on_a_turned_footprint() {
let block = "(footprint \"R:R_0603\" (layer \"F.Cu\") (at 100 50 90) (property \"Reference\" \"R1\" (at 0 -1.43 90) (layer \"F.Fab\") (hide yes) (uuid \"u\") (effects (font (size 0.5 0.5) (thickness 0.08)))))";
let out = set_field_pose(block, "Reference", (100.0, 47.0), 0.0, "F.SilkS", 1.0, 0.15).unwrap();
// 3 mm above the origin on the board is (3, 0) in a footprint turned 90 (KiCad's y down)
assert!(out.contains("(at 3 0)") && out.contains("(layer \"F.SilkS\")") && out.contains("(size 1 1)") && out.contains("(thickness 0.15)") && !out.contains("hide"), "{out}");
let fp = sxedit::parse(&out).unwrap();
let p = fp.kids_tagged("property").next().unwrap();
let at = p.kid("at").unwrap();
let (lx, ly) = (at.f64_at(1).unwrap(), at.f64_at(2).unwrap());
let (s, c) = 90f64.to_radians().sin_cos();
assert!(((100.0 + lx * c + ly * s) - 100.0).abs() < 1e-9 && ((50.0 - lx * s + ly * c) - 47.0).abs() < 1e-9);
}
}
#[cfg(test)]
mod tests {
use super::*;
const CAP: &str = "(footprint \"Capacitor_SMD:C_0805\"\n\t\t(layer \"F.Cu\")\n\t\t(uuid \"u-fp\")\n\t\t(at 10 20 90)\n\t\t(property \"Reference\" \"C7\" (at 0 -1.68 90) (layer \"F.SilkS\") (uuid \"u-ref\") (effects (font (size 1 1) (thickness 0.15))))\n\t\t(property \"Value\" \"1u\" (at 0 1.68 90) (layer \"F.Fab\") (uuid \"u-val\") (effects (font (size 1 1))))\n\t\t(attr smd)\n\t\t(fp_line (start -0.26 -0.73) (end 0.26 -0.73) (stroke (width 0.12) (type solid)) (layer \"F.SilkS\") (uuid \"u-l1\"))\n\t\t(fp_poly (pts (xy -1.7 -0.98) (xy 1.7 -0.98) (xy 1.7 0.98)) (stroke (width 0.05) (type solid)) (fill no) (layer \"F.CrtYd\") (uuid \"u-cy\"))\n\t\t(pad \"1\" smd roundrect (at -0.95 0.2 90) (size 1 1.45) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (roundrect_rratio 0.25) (chamfer top_left) (net \"+VBAT\") (uuid \"u-p1\"))\n\t\t(pad \"2\" thru_hole circle (at 0.95 0 90) (size 1 1) (drill 0.5 (offset 0 0.1)) (layers \"*.Cu\" \"*.Mask\") (net \"GND\") (uuid \"u-p2\"))\n\t\t(model \"x.step\" (offset (xyz 1 0 0)) (scale (xyz 1 1 1)) (rotate (xyz 0 0 90)))\n\t)";
#[test]
fn flip_left_right_mirrors_like_kicad() {
let out = flip_block(CAP, true, 4).unwrap();
let fp = sxedit::parse(&out).unwrap();
assert_eq!(fp.value_of("layer"), Some("B.Cu"));
assert_eq!(pose(&fp), (10.0, 20.0, 90.0)); // 180 - 90
let p1 = fp.kids_tagged("pad").next().unwrap();
let at = p1.kid("at").unwrap();
assert_eq!((at.f64_at(1), at.f64_at(2), at.f64_at(3)), (Some(-0.95), Some(-0.2), Some(90.0)));
assert_eq!(p1.kid("layers").unwrap().kids.iter().skip(1).filter_map(|k| k.atom.as_deref()).collect::<Vec<_>>(), vec!["B.Cu", "B.Mask", "B.Paste"]);
assert_eq!(p1.kid("chamfer").unwrap().atom(1), Some("bottom_left"));
let p2 = fp.kids_tagged("pad").nth(1).unwrap();
assert_eq!(p2.kid("layers").unwrap().atom(1), Some("*.Cu"));
assert_eq!(p2.kid("drill").unwrap().kid("offset").unwrap().f64_at(2), Some(-0.1));
let r = fp.kids_tagged("property").next().unwrap();
assert_eq!(r.value_of("layer"), Some("B.SilkS"));
assert_eq!(r.kid("at").unwrap().f64_at(2), Some(1.68));
assert_eq!(r.kid("at").unwrap().f64_at(3), Some(270.0)); // -90
assert_eq!(r.kid("effects").unwrap().kid("justify").unwrap().atom(1), Some("mirror"));
let cy = fp.kids_tagged("fp_poly").next().unwrap();
assert_eq!(cy.value_of("layer"), Some("B.CrtYd"));
assert_eq!(cy.kid("pts").unwrap().kids[1].f64_at(2), Some(0.98));
// flipping twice is the identity on every coordinate and layer
let back = flip_block(&out, true, 4).unwrap();
let b = sxedit::parse(&back).unwrap();
assert_eq!(pose(&b), (10.0, 20.0, 90.0));
assert_eq!(b.value_of("layer"), Some("F.Cu"));
assert!(b.kids_tagged("property").next().unwrap().kid("effects").unwrap().kid("justify").is_none());
assert_eq!(flip_layer("In1.Cu", 6), "In4.Cu");
assert_eq!(flip_layer("In2.Cu", 4), "In1.Cu");
}
#[test]
fn a_model_the_flip_would_have_to_move_is_refused() {
let bad = CAP.replace("(offset (xyz 1 0 0))", "(offset (xyz 1 0.5 0))");
assert!(flip_block(&bad, true, 2).unwrap_err().contains("3D model"));
}
#[test]
fn move_rotates_absolute_angles_and_fields_attrs_lock_edit_in_place() {
let out = move_block(CAP, 50.0, 60.0, 180.0).unwrap();
let fp = sxedit::parse(&out).unwrap();
assert_eq!(pose(&fp), (50.0, 60.0, 180.0));
assert_eq!(fp.kids_tagged("pad").next().unwrap().kid("at").unwrap().f64_at(3), Some(180.0));
assert_eq!(fp.kids_tagged("property").next().unwrap().kid("at").unwrap().f64_at(3), Some(180.0));
let v = set_property(&out, "Value", "2u2").unwrap();
let v = set_property(&v, "LCSC", "C1234").unwrap();
let fp = sxedit::parse(&v).unwrap();
assert!(properties(&fp).contains(&("Value".into(), "2u2".into())));
assert!(properties(&fp).contains(&("LCSC".into(), "C1234".into())));
let mut ch = serde_json::Map::new();
ch.insert("dnp".into(), json!(true));
ch.insert("excludeFromBom".into(), json!(true));
let a = set_attrs(&v, &ch).unwrap();
let fp = sxedit::parse(&a).unwrap();
assert_eq!(attrs(&fp), json!({"type":"smd","excludeFromPosFiles":false,"excludeFromBom":true,"dnp":true,"boardOnly":false,"allowMissingCourtyard":false,"allowSoldermaskBridges":false}));
let l = set_locked(&a, true).unwrap();
assert!(is_locked(&sxedit::parse(&l).unwrap()));
assert!(!is_locked(&sxedit::parse(&set_locked(&l, false).unwrap()).unwrap()));
assert!(remove_property(&v, "Value").is_err());
let r = remove_property(&v, "LCSC").unwrap();
assert!(!r.contains("LCSC"));
}
#[test]
fn graphics_and_models_list_and_edit() {
let fp = sxedit::parse(CAP).unwrap();
let g = graphics(&fp);
assert_eq!(g.len(), 2);
assert_eq!(g[0]["kind"], "line");
assert_eq!(g[1]["points"].as_array().unwrap().len(), 3);
let e = edit_graphic(CAP, "u-l1", &json!({"width": 0.2, "layer": "F.Fab", "end": [0.3, -0.73]})).unwrap();
let g = graphics(&sxedit::parse(&e).unwrap());
assert_eq!((g[0]["width"].clone(), g[0]["layer"].clone(), g[0]["end"].clone()), (json!(0.2), json!("F.Fab"), json!([0.3, -0.73])));
assert!(edit_graphic(CAP, "nope", &json!({"width": 1})).is_err());
let d = edit_graphic(CAP, "u-cy", &json!({"delete": true})).unwrap();
assert_eq!(graphics(&sxedit::parse(&d).unwrap()).len(), 1);
let m = edit_models(CAP, &[json!({"action":"replace","index":0,"offset":[0,0,0.5]}), json!({"action":"add","path":"${KIPRJMOD}/y.step","rotate":[0,0,90]})]).unwrap();
let ms = models(&sxedit::parse(&m).unwrap());
assert_eq!(ms.len(), 2);
assert_eq!(ms[0]["offset"], json!([0.0, 0.0, 0.5]));
assert_eq!(ms[0]["rotate"], json!([0.0, 0.0, 90.0]));
assert_eq!(ms[1]["path"], "${KIPRJMOD}/y.step");
let r = edit_models(&m, &[json!({"action":"remove","path":"x.step"})]).unwrap();
assert_eq!(models(&sxedit::parse(&r).unwrap()).len(), 1);
}
#[test]
fn a_library_footprint_lands_on_the_back_with_fresh_ids() {
let lib = "(footprint \"R_0603\" (version 20240108) (generator \"pcbnew\") (layer \"F.Cu\") (descr \"r\")\n (property \"Reference\" \"REF**\" (at 0 -1.4 0) (layer \"F.SilkS\") (uuid \"aaa\") (effects (font (size 1 1))))\n (property \"Value\" \"R_0603\" (at 0 1.4 0) (layer \"F.Fab\") (uuid \"bbb\") (effects (font (size 1 1))))\n (attr smd)\n (pad \"1\" smd roundrect (at -0.8 0) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (uuid \"ccc\"))\n (pad \"2\" smd roundrect (at 0.8 0) (size 0.8 0.9) (layers \"F.Cu\" \"F.Mask\" \"F.Paste\") (uuid \"ddd\"))\n)";
let out = place_library(lib, "Resistor_SMD:R_0603", 100.0, 50.0, 90.0, true, "R7", Some("10k"), 2).unwrap();
let fp = sxedit::parse(&out).unwrap();
assert_eq!(fp.atom(1), Some("Resistor_SMD:R_0603"));
assert_eq!(fp.value_of("layer"), Some("B.Cu"));
assert_eq!(pose(&fp), (100.0, 50.0, 90.0));
assert!(fp.kid("version").is_none() && fp.kid("generator").is_none());
assert!(!out.contains("\"ccc\"") && !out.contains("\"aaa\""));
assert!(properties(&fp).contains(&("Reference".into(), "R7".into())));
let p1 = fp.kids_tagged("pad").next().unwrap();
// flipped left/right at rotation 0 (orientation 180), then turned to 90: pad angle 180 - 0 + (90 - 180)
assert_eq!(p1.kid("at").unwrap().f64_at(3), Some(90.0));
assert_eq!(p1.kid("layers").unwrap().atom(1), Some("B.Cu"));
// parses as a board footprint
let board = format!("(kicad_pcb (version 20260206) (generator \"t\") {out})");
let b = crate::pcb::parse_pcb_text(&board, "t.kicad_pcb").unwrap();
assert_eq!(b.footprints[0].reference, "R7");
}
}