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 #307: kicad-lane: 1.0.64, live adds through KiCad's protobuf API, #126/#129, 17 standard core verbs
a8fbf9f
14d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
//! Reference/value field geometry, with file-local coordinates kept out of the API.
use crate::schematic::{parse_node, Document, Kind, Node};
use crate::{pcb::RoutingError, silk_text::Text};
use serde_json::{json, Value};
use std::collections::HashSet;
fn fail(s: impl Into<String>) -> RoutingError {
RoutingError::new("silk_field_refused", s).with("mutated", json!(false))
}
pub struct Edit {
pub text: Text,
pub visible: bool,
pub footprint_id: String,
pub name: String,
}
pub struct Plan {
pub candidate: String,
pub edits: Vec<Edit>,
}
fn locked(n: &Node) -> bool {
n.child("locked")
.map(|x| x.arg(0).as_deref() != Some("no"))
.unwrap_or(false)
}
pub fn prepare(source: &str, args: &Value) -> Result<Plan, RoutingError> {
let rows = args["update"]
.as_array()
.filter(|a| !a.is_empty() && a.len() <= 2000)
.ok_or_else(|| fail("update requires 1..2000 complete geometry definitions"))?;
for k in ["create", "remove"] {
if args.get(k).is_some() {
return Err(fail(
"fields are updated, never created or removed by this verb",
));
}
}
let mut doc = Document::parse(source).map_err(fail)?;
let mut edits = Vec::new();
let mut seen = HashSet::new();
for row in rows {
let id = row["id"]
.as_str()
.ok_or_else(|| fail("field UUID is required"))?;
if !seen.insert(id.to_string()) {
return Err(fail("duplicate field UUID"));
}
if row.get("text").is_some() {
return Err(fail(
"reference/value content is preserved; text is not editable here",
));
}
let visible = row
.get("visible")
.map(Value::as_bool)
.unwrap_or(Some(true))
.ok_or_else(|| fail("visible must be boolean"))?;
let fp = doc
.root
.children_mut()
.unwrap()
.iter_mut()
.find(|fp| {
fp.head() == Some("footprint")
&& fp.children().iter().any(|p| {
p.head() == Some("property") && p.child_value("uuid").as_deref() == Some(id)
})
})
.ok_or_else(|| fail("UUID is not a footprint reference/value property"))?;
if locked(fp) {
return Err(fail("footprint is locked"));
}
let fp_id = fp
.child_value("uuid")
.ok_or_else(|| fail("footprint has no UUID"))?;
let at = fp
.child("at")
.ok_or_else(|| fail("footprint has no pose"))?;
let number = |i| at.arg(i).and_then(|x| x.parse::<f64>().ok());
let (x, y, rot) = (
number(0).ok_or_else(|| fail("invalid footprint x"))?,
number(1).ok_or_else(|| fail("invalid footprint y"))?,
number(2).unwrap_or(0.),
);
let p = fp
.children_mut()
.unwrap()
.iter_mut()
.find(|p| p.head() == Some("property") && p.child_value("uuid").as_deref() == Some(id))
.unwrap();
let name = p.arg(0).unwrap_or_default();
if !matches!(name.as_str(), "Reference" | "Value") || locked(p) {
return Err(fail("only unlocked Reference/Value fields are supported"));
}
let original = p.arg(1).ok_or_else(|| fail("field has no text"))?;
let mut v = row.clone();
v.as_object_mut()
.ok_or_else(|| fail("update row must be an object"))?
.remove("visible");
v["text"] = json!(original);
let t = Text::parse(&v, id.into())?;
let local = crate::placement::to_board((0., 0.), -rot, (t.x - x, t.y - y));
let node = t.node();
let children = p.children_mut().unwrap();
children.retain(|n| !matches!(n.head(), Some("at" | "layer" | "effects" | "hide")));
children.push(parse_node(&format!("(at {} {} {})", local.0, local.1, t.rotation)).unwrap());
children.push(node.child("layer").unwrap().clone());
children.push(node.child("effects").unwrap().clone());
if !visible {
children.push(parse_node("(hide yes)").unwrap());
}
edits.push(Edit {
text: t,
visible,
footprint_id: fp_id,
name,
});
}
Ok(Plan {
candidate: doc.to_text(),
edits,
})
}
/// True for a `(pts ...)` list whose last point repeats its first: a closed polygon written
/// with its closing vertex, as KiCad's libraries and file format keep it.
fn repeats_first_point(n: &Node) -> bool {
let pts = n.children();
if n.head() != Some("pts") || pts.len() < 5 {
return false;
}
let (first, last) = (&pts[1], &pts[pts.len() - 1]);
first.head() == Some("xy") && last.head() == Some("xy") && first.arg(0) == last.arg(0) && first.arg(1) == last.arg(1)
}
/// Per footprint uuid, how many of its polygons carry the repeated closing vertex. KiCad's IPC
/// API rebuilds every footprint it updates (a move, a field edit) and stores each closed polygon
/// without that vertex: the same geometry, but KiCad's DRC then reports the part as differing from
/// its library (wiki #126). Comparing this count before and after an update names those parts.
pub fn closing_vertices(source: &str) -> std::collections::HashMap<String, usize> {
fn count(n: &Node) -> usize {
usize::from(repeats_first_point(n)) + n.children().iter().map(count).sum::<usize>()
}
let mut out = std::collections::HashMap::new();
if let Ok(doc) = Document::parse(source) {
for fp in doc.root.children().iter().filter(|c| c.head() == Some("footprint")) {
if let Some(id) = fp.child_value("uuid") {
out.insert(id, count(fp));
}
}
}
out
}
/// What a reply says when KiCad's IPC API dropped closing vertices from footprints it rebuilt.
pub const CLOSING_VERTEX_HINT: &str = "KiCad's IPC API rebuilds every footprint it updates and stores each closed polygon without its repeated closing point: the same geometry, KiCad's behaviour (not an edit by the bridge). KiCad's DRC may then report lib_footprint_mismatch for these footprints. Tools > Update Footprints from Library in the PCB editor (or kicad_update_footprints_from_library on the saved, closed board) brings back the library's copy; check its options so it keeps your placement and fields.";
/// The footprints among `ids` that lost closing vertices between `before` and `after`, with how
/// many: `[{id, dropped}]`, empty when none did.
pub fn dropped_closing_vertices(before: &str, after: &str, ids: &[String]) -> Vec<Value> {
let (b, a) = (closing_vertices(before), closing_vertices(after));
ids.iter()
.filter_map(|id| {
let lost = b.get(id).copied().unwrap_or(0).saturating_sub(a.get(id).copied().unwrap_or(0));
(lost > 0).then(|| json!({"id": id, "dropped": lost}))
})
.collect()
}
/// Ignore only the explicitly edited property nodes, never pads, model data or other text.
pub fn preserved_geometry(source: &str, ids: &HashSet<String>) -> Result<Value, RoutingError> {
fn visit(n: &Node, ids: &HashSet<String>) -> Value {
match &n.kind {
Kind::Atom(a) => json!(a),
Kind::List { children, .. } => {
// a polygon's repeated closing vertex is the same geometry: KiCad's IPC API drops
// it whenever it rebuilds a footprint, so it is not a change to the board
let kept = if repeats_first_point(n) { &children[..children.len() - 1] } else { &children[..] };
Value::Array(
kept.iter()
.filter(|c| {
!(c.head() == Some("property")
&& c.child_value("uuid")
.map(|id| ids.contains(&id))
.unwrap_or(false))
})
.map(|c| visit(c, ids))
.collect(),
)
}
}
}
Ok(visit(&Document::parse(source).map_err(fail)?.root, ids))
}
/// Where two preserved_geometry trees first differ, as readable s-expression paths (at most `limit`).
pub fn preservation_diff(a: &Value, b: &Value, limit: usize) -> Vec<String> {
fn label(v: &Value) -> String {
match v {
Value::Array(x) => x.first().and_then(Value::as_str).unwrap_or("?").to_string(),
Value::String(s) => s.clone(),
_ => v.to_string(),
}
}
fn short(v: &Value) -> String {
let s = v.to_string();
if s.len() > 160 { format!("{}...", &s[..160]) } else { s }
}
fn walk(a: &Value, b: &Value, path: &str, out: &mut Vec<String>, limit: usize) {
if out.len() >= limit || a == b {
return;
}
match (a, b) {
(Value::Array(x), Value::Array(y)) if x.len() == y.len() => {
for (i, (p, q)) in x.iter().zip(y).enumerate() {
walk(p, q, &format!("{path}/{}[{i}]", label(p)), out, limit);
}
}
_ => out.push(format!("{path}: {} -> {}", short(a), short(b))),
}
}
let mut out = Vec::new();
walk(a, b, "", &mut out, limit);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn board(side: &str, angle: i32) -> String {
format!(
r#"(kicad_pcb
(footprint "P" (uuid fp) (layer "{side}") (at 10 20 {angle})
(property "Reference" "R1" (at 0 0) (layer "F.SilkS") (uuid field) (effects (font (size 1 1))))
(property "Value" "10k" (at 0 1) (layer "F.Fab") (uuid value))
(pad "1" smd rect (at 1 2) (size 1 1) (layers "F.Cu" "F.Mask") (uuid pad))
(model "chosen.step" (offset (xyz 0 0 0)) (scale (xyz 1 1 1))))
(gr_text "R1" (uuid other)))"#
)
}
#[test]
fn field_identity_and_rotated_both_face_coordinates() {
for side in ["F.Cu", "B.Cu"] {
let b = board(side, 90);
let p=prepare(&b,&json!({"update":[{"id":"field","x":12,"y":19,"rotation":90,"layer":"B.SilkS","height":0.7,"stroke":0.1,"align":"right","visible":false}]})).unwrap();
let d = Document::parse(&p.candidate).unwrap();
let fp = d.root.child("footprint").unwrap();
let f = fp
.children()
.iter()
.find(|n| n.head() == Some("property"))
.unwrap();
let at = f.child("at").unwrap();
assert!((at.arg(0).unwrap().parse::<f64>().unwrap() - 1.).abs() < 1e-9);
assert!((at.arg(1).unwrap().parse::<f64>().unwrap() - 2.).abs() < 1e-9);
assert!(p.edits[0].text.mirrored);
assert_eq!(f.arg(1).as_deref(), Some("R1"));
assert_eq!(f.child_value("uuid").as_deref(), Some("field"));
let ids = HashSet::from(["field".to_string()]);
assert_eq!(
preserved_geometry(&b, &ids).unwrap(),
preserved_geometry(&p.candidate, &ids).unwrap()
);
}
}
#[test]
fn refuses_content_changes_foreign_ids_and_duplicates() {
let b = board("F.Cu", 0);
let row = json!({"id":"field","x":1,"y":2,"layer":"F.SilkS","height":0.5,"stroke":0.1});
assert!(prepare(&b, &json!({"update":[row.clone(),row.clone()]})).is_err());
for (k, v) in [
("id", json!("other")),
("text", json!("R999")),
("layer", json!("F.Cu")),
("visible", json!("yes")),
] {
let mut r = row.clone();
r[k] = v;
assert!(prepare(&b, &json!({"update":[r]})).is_err());
}
}
}
#[cfg(test)]
mod closing_vertex_tests {
use super::*;
const BEFORE: &str = "(kicad_pcb\n\t(footprint \"X\"\n\t\t(uuid \"fp1\")\n\t\t(fp_poly\n\t\t\t(pts\n\t\t\t\t(xy -2.4 -2.4) (xy -2.9 -2.4) (xy -2.4 -2.9) (xy -2.4 -2.4)\n\t\t\t)\n\t\t)\n\t)\n)\n";
#[test]
fn a_dropped_closing_vertex_is_the_same_geometry_and_is_counted() {
let after = BEFORE.replace(" (xy -2.4 -2.4)\n", "\n");
assert_ne!(BEFORE, after);
let none = HashSet::new();
assert_eq!(preserved_geometry(BEFORE, &none).unwrap(), preserved_geometry(&after, &none).unwrap());
assert_eq!(closing_vertices(BEFORE).get("fp1"), Some(&1));
assert_eq!(closing_vertices(&after).get("fp1"), Some(&0));
// a real change is still one
let moved = after.replace("(xy -2.9 -2.4)", "(xy -3.0 -2.4)");
assert_ne!(preserved_geometry(BEFORE, &none).unwrap(), preserved_geometry(&moved, &none).unwrap());
}
}