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.
← Commit history
1.0.22: NATIVE (Rust) build. 1.0.22: merges Astra PRs #8-#11: kicad_apply_zone_plan (owned zone reconciliation, one Undo step, full-candidate DRC; wiki #95, #101), kicad_model_check modelBindings inventory (#101), kicad_silk_text_batch (stable-ID silkscreen text edits, one Undo step, warnings rejected; #104), DRC snapshots fail closed when project rules cannot be copied, esc-demo docs for Astra's takes (#94, #96).
2 files changed
+151
rust/crates/kicad-core/src/silk_text.rsadded+53@@ -0,0 +1,53 @@+//! Stable-ID standalone silkscreen text plans. Geometry search belongs to the caller.+use serde_json::{json,Value};+use std::collections::HashSet;+use crate::{pcb::RoutingError,schematic::{Document,Node,parse_node,new_uuid}};+fn fail(s:impl Into<String>)->RoutingError{RoutingError::new("silk_text_refused",s).with("mutated",json!(false))}+#[derive(Clone,Debug)]pub struct Text {pub id:String,pub text:String,pub layer:String,pub x:f64,pub y:f64,pub rotation:f64,pub height:f64,pub width:f64,pub stroke:f64,pub align:String,pub mirrored:bool}+impl Text {+ fn parse(v:&Value,id:String)->Result<Self,RoutingError>{+ const KEYS:&[&str]=&["id","text","layer","x","y","rotation","height","width","stroke","align","mirrored"];+ let o=v.as_object().ok_or_else(||fail("text row must be an object"))?;+ if let Some(k)=o.keys().find(|k|!KEYS.contains(&k.as_str())){return Err(fail(format!("unsupported text field {k}; native stroke-font text only")));}+ let num=|k:&str,default:Option<f64>|->Result<f64,RoutingError>{let n=match v.get(k){Some(x)=>x.as_f64(),None=>default}.ok_or_else(||fail(format!("{k} must be a number")))?;if !n.is_finite()||n.abs()>100000.{Err(fail(format!("{k} is outside supported finite range")))}else{Ok(n)}};+ let text=v["text"].as_str().filter(|s|!s.is_empty()&&s.len()<=4096).ok_or_else(||fail("text must contain 1..4096 bytes"))?.into();+ let layer=v["layer"].as_str().filter(|s|matches!(*s,"F.SilkS"|"B.SilkS")).ok_or_else(||fail("layer must be F.SilkS or B.SilkS"))?.to_string();+ let height=num("height",None)?;let width=num("width",Some(height))?;let stroke=num("stroke",None)?;+ if height<=0.||width<=0.||stroke<=0.||height>100.||width>100.||stroke>height.min(width){return Err(fail("positive text dimensions required; stroke must not exceed height/width"));}+ let align=match v.get("align"){None=>"center",Some(v)=>v.as_str().ok_or_else(||fail("align must be a string"))?};if !matches!(align,"left"|"center"|"right"){return Err(fail("align must be left, center or right"));}+ let mirrored=match v.get("mirrored"){Some(v)=>v.as_bool().ok_or_else(||fail("mirrored must be boolean"))?,None=>layer=="B.SilkS"};+ Ok(Self{id,text,layer,x:num("x",None)?,y:num("y",None)?,rotation:num("rotation",Some(0.))?,height,width,stroke,align:align.into(),mirrored})+ }+ pub fn node(&self)->Node {+ let quote=|s:&str|serde_json::to_string(s).unwrap();+ let justify=format!("{} {}",if self.align=="center"{""}else{&self.align},if self.mirrored{"mirror"}else{""});+ let justify=if justify.trim().is_empty(){String::new()}else{format!("(justify {})",justify.trim())};+ parse_node(&format!("(gr_text {} (at {} {} {}) (layer {}) (uuid {}) (effects (font (size {} {}) (thickness {})) {}))",quote(&self.text),self.x,self.y,self.rotation,quote(&self.layer),quote(&self.id),self.width,self.height,self.stroke,justify)).unwrap()+ }+}+pub struct Plan {pub candidate:String,pub create:Vec<Text>,pub update:Vec<Text>,pub remove:Vec<String>}+pub fn prepare(text:&str,args:&Value)->Result<Plan,RoutingError>{+ let mut doc=Document::parse(text).map_err(fail)?;let mut touched=HashSet::new();+ let existing:Vec<_>=doc.root.children().iter().filter(|n|n.head()==Some("gr_text")).cloned().collect();+ let all_ids:HashSet<_>=doc.root.children().iter().filter_map(|n|n.child_value("uuid")).collect();+ let editable=|id:&str|->Result<(),RoutingError>{let n=existing.iter().find(|n|n.child_value("uuid").as_deref()==Some(id)).ok_or_else(||fail(format!("{id} is not standalone board text")))?;+ if !matches!(n.child_value("layer").as_deref(),Some("F.SilkS"|"B.SilkS"))||n.child("locked").map(|n|n.arg(0).as_deref()!=Some("no")).unwrap_or(false){return Err(fail(format!("{id} is locked or is not silkscreen text")));}Ok(())};+ let rows=|key:&str|->Result<Vec<Value>,RoutingError>{match args.get(key){None=>Ok(vec![]),Some(v)=>v.as_array().cloned().filter(|a|a.len()<=2000).ok_or_else(||fail(format!("{key} must be an array with at most 2000 items")))}};+ let mut create=Vec::new();let mut update=Vec::new();let mut remove=Vec::new();+ for v in rows("create")? {if v.get("id").is_some(){return Err(fail("create IDs are assigned by the bridge; use update to preserve an existing ID"));}let id=new_uuid();if all_ids.contains(&id){return Err(fail("generated ID collision"));}create.push(Text::parse(&v,id)?);}+ for v in rows("update")? {let id=v["id"].as_str().ok_or_else(||fail("update requires a stable item id"))?;editable(id)?;if !touched.insert(id.to_string()){return Err(fail("duplicate update/remove ID"));}update.push(Text::parse(&v,id.into())?);}+ for v in rows("remove")? {let id=v.as_str().ok_or_else(||fail("remove entries must be stable item IDs"))?;editable(id)?;if !touched.insert(id.to_string()){return Err(fail("duplicate update/remove ID"));}remove.push(id.to_string());}+ if create.is_empty()&&update.is_empty()&&remove.is_empty(){return Err(fail("provide at least one create, update or remove"));}+ let cs=doc.root.children_mut().ok_or_else(||fail("invalid root"))?;cs.retain(|n|!n.child_value("uuid").map(|id|touched.contains(&id)).unwrap_or(false));cs.extend(create.iter().chain(&update).map(Text::node));+ Ok(Plan{candidate:doc.to_text(),create,update,remove})+}+#[cfg(test)]mod tests {+ use super::*;+ #[test]fn duplicate_content_is_never_identity(){let text="(kicad_pcb (gr_text \"GND\" (at 1 2) (layer \"F.SilkS\") (uuid a)) (gr_text \"GND\" (at 3 4) (layer \"F.SilkS\") (uuid b)))";+ let p=prepare(text,&json!({"remove":["a"]})).unwrap();assert!(p.candidate.contains("uuid b"));assert!(!p.candidate.contains("uuid a"));+ assert!(prepare(text,&json!({"remove":["a","a"]})).is_err());assert!(prepare(text,&json!({"remove":["GND"]})).is_err());}+ #[test]fn bottom_mirror_alignment_and_id_survive(){let text="(kicad_pcb (gr_text \"old\" (layer \"B.SilkS\") (uuid x)))";+ let p=prepare(text,&json!({"update":[{"id":"x","text":"MC10\nDSHOT","layer":"B.SilkS","x":10,"y":20,"height":0.5,"stroke":0.07,"align":"left"}]})).unwrap();assert_eq!(p.update[0].id,"x");assert!(p.update[0].mirrored);assert!(p.candidate.contains("left mirror"));assert_eq!(p.candidate.matches("gr_text").count(),1);}+ #[test]fn refuses_locked_non_silk_and_unknown_attributes(){for extra in ["(locked yes)","(layer F.Cu)"]{let text=format!("(kicad_pcb (gr_text x (uuid a) {extra}))");assert!(prepare(&text,&json!({"remove":["a"]})).is_err());}assert!(Text::parse(&json!({"text":"x","layer":"F.SilkS","x":0,"y":0,"height":1,"stroke":0.1,"font":"Arial"}),"a".into()).is_err());}+}+
rust/crates/kicad-core/src/zone_plan.rsadded+98@@ -0,0 +1,98 @@+//! 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() { json!(n.children().iter().map(canonical).collect::<Vec<_>>()) }+ else { json!(n.value()) }+}+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()));+ }+ }+}+