app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
master
John Lauer
Verify deletions after commit and separate zone refill from reconciliation
98495bb
1mo ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
//! Conservative native zone-plan preparation. Ownership is explicit; full definitions,
//! including holes, arcs and settings, decide equality. Equal area never does.
use std::collections::HashSet;
use serde_json::{json, Value};
use crate::{pcb::{Board, RoutingError}, schematic::{Document, Node}, zones::{self,ZoneSpec}};
pub struct Prepared {
pub candidate: String,
pub remove: Vec<String>,
pub unchanged: Vec<String>,
pub create: Vec<ZoneSpec>,
}
fn fail(message: impl Into<String>) -> RoutingError {
RoutingError::new("zone_plan_refused",message).with("mutated",json!(false))
}
fn canonical(n: &Node) -> Value {
if !n.is_list() {
return json!(n.value());
}
let head = n.head().unwrap_or("");
let mut children: Vec<Value> = n
.children()
.iter()
.filter(|c| {
// KiCad 10 omits this legacy no-thickness marker and zero priority.
!matches!(
(c.head(), c.arg(0).as_deref()),
(Some("filled_areas_thickness"), Some("no"))
| (Some("priority"), Some("0"))
| (Some("locked"), Some("no"))
)
})
.map(canonical)
.collect();
// Fill yes describes generated fill state, not the requested copper definition.
if head == "fill" {
children.retain(|v| v != "yes");
}
// Metadata order is insignificant. Polygon/point order and all unknown
// properties remain significant; never collapse holes or arcs to area.
if matches!(head, "zone" | "fill" | "connect_pads" | "keepout") {
children.sort_by_key(Value::to_string);
}
json!(children)
}
fn definition_node(n: &Node) -> Node {
let mut copy=n.clone();
if let Some(cs)=copy.children_mut() {
cs.retain(|c| !matches!(c.head(),Some("uuid"|"filled_polygon"|"fill_segments")));
}
copy
}
pub fn definition(n: &Node) -> Value { canonical(&definition_node(n)) }
pub fn readback(text: &str) -> Result<Value,String> {
let doc=Document::parse(text)?;
Ok(json!(doc.root.children().iter().filter(|n|n.head()==Some("zone")).map(|n|json!({"id":n.child_value("uuid"),"definition":definition(n),"definitionSexpr":definition_node(n).to_text()})).collect::<Vec<_>>()))
}
pub fn prepare(text:&str, board:&Board, args:&Value) -> Result<Prepared,RoutingError> {
let ids=args["ownedItemIds"].as_array().ok_or_else(||fail("ownedItemIds must be an explicit array, empty for a first plan"))?;
if ids.len()>256 || ids.iter().any(|v|v.as_str().map(|s|s.is_empty()).unwrap_or(true)) { return Err(fail("ownedItemIds must contain at most 256 nonempty strings")); }
let owned:HashSet<String>=ids.iter().map(|v|v.as_str().unwrap().to_string()).collect();
if owned.len()!=ids.len(){return Err(fail("duplicate ownedItemIds"));}
let rows=args["zones"].as_array().ok_or_else(||fail("zones must be an explicit array, empty to remove only owned zones"))?;
for row in rows {
if row.get("holes").is_some() || row.get("arcs").is_some() { return Err(fail("desired holes/arcs are not supported by this polygon request schema; no edit performed")); }
}
let specs=if rows.is_empty(){Vec::new()}else{zones::parse_specs(board,args)?};
let mut doc=Document::parse(text).map_err(fail)?;
let existing:Vec<Node>=doc.root.children().iter().filter(|n|n.head()==Some("zone")).cloned().collect();
for id in &owned {
let n=existing.iter().find(|n|n.child_value("uuid").as_ref()==Some(id)).ok_or_else(||fail(format!("owned ID {id} is not a live zone")))?;
if n.child("locked").map(|c|c.arg(0).as_deref()!=Some("no")).unwrap_or(false) || n.child("teardrop").is_some(){return Err(fail(format!("owned zone {id} is locked or a teardrop")));}
}
let mut names=HashSet::new();
let mut unchanged=Vec::new();let mut create=Vec::new();let mut blocks=Vec::new();
for spec in specs {
if spec.name.is_empty() || !names.insert(spec.name.clone()){return Err(fail("every planned zone requires a unique nonempty name"));}
let matching:Vec<&Node>=existing.iter().filter(|n|n.child_value("name").as_deref()==Some(&spec.name)).collect();
if matching.len()>1 || matching.iter().any(|n|!owned.contains(&n.child_value("uuid").unwrap_or_default())){return Err(fail(format!("zone name {:?} is ambiguous or belongs to an unowned zone",spec.name)));}
let block=zones::zone_sexpr(&spec);let node=crate::schematic::parse_node(&block).map_err(fail)?;
if let Some(old)=matching.first().filter(|n|definition(n)==definition(&node)) {
unchanged.push(old.child_value("uuid").unwrap());
} else {blocks.push(node);create.push(spec);}
}
let remove:Vec<String>=owned.into_iter().filter(|id|!unchanged.contains(id)).collect();
let cs=doc.root.children_mut().ok_or_else(||fail("invalid board root"))?;
cs.retain(|n|n.head()!=Some("zone") || !remove.contains(&n.child_value("uuid").unwrap_or_default()));
cs.extend(blocks);
Ok(Prepared{candidate:doc.to_text(),remove,unchanged,create})
}
#[cfg(test)] mod tests {
use super::*;
fn fixture() -> (String,Board,Value) {
let text="(kicad_pcb (version 20240108) (layers (0 \"F.Cu\" signal) (31 \"B.Cu\" signal)) (footprint \"X\" (at 1 2) (pad \"1\" smd rect (at 0 0) (size 1 1) (layers \"F.Cu\") (net \"GND\"))) (gr_rect (start 0 0) (end 50 40) (layer \"Edge.Cuts\")))";
let board=crate::pcb::parse_pcb_text(text,"test.kicad_pcb").unwrap();
let request=json!({"net":"GND","name":"power","layers":["F.Cu"],"polygon":[[2,2],[8,2],[8,8],[2,8]]});
(text.into(),board,request)
}
#[test]fn preserves_foreign_and_refuses_ownership_conflicts() {
let (text,board,request)=fixture();
let first=prepare(&text,&board,&json!({"ownedItemIds":[],"zones":[request.clone()]})).unwrap();
let doc=Document::parse(&first.candidate).unwrap();
let id=doc.root.children().iter().find(|n|n.head()==Some("zone")).unwrap().child_value("uuid").unwrap();
let unrelated=prepare(&first.candidate,&board,&json!({"ownedItemIds":[],"zones":[]})).unwrap();
assert_eq!(unrelated.candidate,first.candidate);
assert!(prepare(&first.candidate,&board,&json!({"ownedItemIds":[],"zones":[request.clone()]})).is_err());
let same=prepare(&first.candidate,&board,&json!({"ownedItemIds":[id.clone()],"zones":[request.clone()]})).unwrap();
assert_eq!(same.unchanged,vec![id.clone()]);assert!(same.remove.is_empty() && same.create.is_empty());
let mut changed=request;changed["clearance"]=json!(0.5);
let update=prepare(&first.candidate,&board,&json!({"ownedItemIds":[id.clone()],"zones":[changed]})).unwrap();
assert_eq!(update.remove,vec![id]);assert_eq!(update.create.len(),1);
assert!(prepare(&text,&board,&json!({"ownedItemIds":["foreign"],"zones":[]})).is_err());
assert!(prepare(&text,&board,&json!({"ownedItemIds":[],"zones":[{"holes":[]}]})).is_err());
}
#[test]fn equality_keeps_shape_holes_and_rules() {
let a=crate::schematic::parse_node("(zone (uuid a) (name p) (layer F.Cu) (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1))))").unwrap();
let b=crate::schematic::parse_node("(zone (uuid b) (name p) (layer F.Cu) (polygon (pts (xy 0 0) (xy 1 0) (xy 1 1))) (filled_polygon (pts (xy 0 0))))").unwrap();
assert_eq!(definition(&a),definition(&b));
for changed in [a.to_text().replace("F.Cu","B.Cu"),a.to_text().replace("(xy 0 0)","(xy 10 10)"),a.to_text().replace("(name p)","(name p) (keepout (tracks not_allowed))")] {
assert_ne!(definition(&a),definition(&crate::schematic::parse_node(&changed).unwrap()));
}
}
#[test]
fn native_saved_zone_defaults_are_idempotent() {
let (text, board, request) = fixture();
let first = prepare(&text, &board, &json!({"ownedItemIds": [], "zones": [request.clone()]})).unwrap();
let doc = Document::parse(&first.candidate).unwrap();
let zone = doc.root.children().iter().find(|n| n.head() == Some("zone")).unwrap();
let id = zone.child_value("uuid").unwrap();
// KiCad's saved copper zone omits the legacy filled_areas marker,
// moves island mode after thermal settings, and carries generated fills.
let mut saved = zone.clone();
saved.children_mut().unwrap().retain(|n| n.head() != Some("filled_areas_thickness"));
let fill = saved.children_mut().unwrap().iter_mut().find(|n| n.head() == Some("fill")).unwrap();
let children = fill.children_mut().unwrap();
let index = children.iter().position(|n| n.head() == Some("island_removal_mode")).unwrap();
let mode = children.remove(index); children.push(mode);
saved.children_mut().unwrap().push(crate::schematic::parse_node("(filled_polygon (layer F.Cu) (pts (xy 2 2) (xy 8 2) (xy 8 8)))").unwrap());
assert_eq!(definition(zone), definition(&saved));
let native_text = first.candidate.replace(&zone.to_text(), &saved.to_text());
let again = prepare(&native_text, &board, &json!({"ownedItemIds": [id.clone()], "zones": [request]})).unwrap();
assert_eq!(again.unchanged, vec![id]);
assert!(again.remove.is_empty() && again.create.is_empty());
for extra in ["(island_removal_mode 1)", "(island_removal_mode 2)"] {
let other = saved.to_text().replace("(island_removal_mode 0)", extra);
assert_ne!(definition(&saved), definition(&crate::schematic::parse_node(&other).unwrap()));
}
}
}