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).
6 files changed
+146−6
rust/Cargo.lock+3−3@@ -244,7 +244,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.21"+version = "1.0.22" dependencies = [ "kicad-core", "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.21"+version = "1.0.22" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -281,7 +281,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.21"+version = "1.0.22" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.21"+version = "1.0.22" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_routing.rs+25@@ -27,6 +27,15 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_routing_state"], pitfalls: &["Bodies, holes and exposed pads remain separate obstacles."], },+ Verb {+ name: "kicad_silk_text_batch", summary: "Create, update and remove standalone silkscreen text by stable UUID with native Undo and DRC preflight.",+ mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+ input: "{filePath,expectedRevision,create?:[text definitions],update?:[full text definitions with id],remove?:[ids],dryRun?:true,save?:false}",+ example: "kicad_silk_text_batch {filePath,expectedRevision,create:[{text:MC10,layer:F.SilkS,x:10,y:20,height:0.8,stroke:0.12,align:left}]}",+ hint: "Definitions: text, layer F.SilkS/B.SilkS, x/y/height/stroke in mm; optional width, rotation, align and mirrored. Bottom defaults mirrored. IDs from prior replies, never string matching. Native DRC blocks new warnings too. Full updates replace supported text formatting. One call is one Undo step; use individual calls for progressive video.",+ related: &["kicad_routing_state", "kicad_routing_validate"],+ pitfalls: &["Only standalone stroke-font gr_text: footprint reference/value fields, curves and other layers are refused.","postCommitError means the edit landed; inspect state before replay. Does not refresh a 3D viewer or save unless requested."],+ }, Verb { name: "kicad_routing_state", summary: "Inspect the live editor: pads, copper, revision and KiCad-measured net connectivity.",@@ -67,6 +76,16 @@ pub static VERBS: &[Verb] = &[ related: &["kicad_zone_state", "kicad_route_net", "kicad_routing_validate", "kicad_routing_state"], pitfalls: &["A zone is copper on every listed layer inside the polygon that clears other nets; it does not connect two islands of its net by itself: check kicad_routing_validate for unconnected items after the fill.", "Do not pour switching nodes or sensitive analog nets wide; see the kicad-copper-pours skill for which nets get a pour and which do not.", "Coordinates are mm, +y down, board frame."], },+ Verb {+ name: "kicad_apply_zone_plan",+ summary: "Reconcile a complete explicit-owned zone plan as one native Undo step after full-candidate DRC.",+ mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,+ input: "{filePath, expectedRevision, ownedItemIds:[], zones:[{name,net,layers,polygon,...}], dryRun?, refill?, save?}",+ example: "kicad_apply_zone_plan {filePath,expectedRevision,ownedItemIds:[],zones:[]}",+ hint: "Use exact owned item IDs from prior successful replies. Unowned zones are preserved; ambiguous names refuse. Dry run has no edit or save. Returns unchangedIds, removedIds and ownedItemIds. Full native definitions, not area, determine equality.",+ related: &["kicad_zone_state", "kicad_add_zone"],+ pitfalls: &["Desired holes and arcs are refused by this polygon request schema; existing unowned holes/arcs remain untouched.", "Inspect postCommitError before retrying a committed edit."],+ }, Verb { name: "kicad_zone_state", summary: "Read every zone on the live board with KiCad's filled copper area per layer and the coverage.",@@ -181,12 +200,14 @@ pub static VERBS: &[Verb] = &[ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> { Some(match command {+ "kicad_silk_text_batch" => live(state,args,Live::SilkTextBatch), "kicad_routing_state" => live(state, args, Live::State), "kicad_text_bounds" => live(state,args,Live::TextBounds), "kicad_route_net" => live(state, args, Live::RouteNet), "kicad_remove_route" => live(state, args, Live::RemoveRoute), "kicad_routing_validate" => live(state, args, Live::Validate), "kicad_add_zone" => live(state, args, Live::AddZone),+ "kicad_apply_zone_plan" => live(state,args,Live::ApplyZonePlan), "kicad_zone_state" => live(state, args, Live::ZoneState), "kicad_remove_zone" => live(state, args, Live::RemoveZone), "kicad_select_net" => live(state, args, Live::SelectNet),@@ -207,12 +228,14 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> #[derive(Clone, Copy)] enum Live {+ SilkTextBatch, State, TextBounds, RouteNet, RemoveRoute, Validate, AddZone,+ ApplyZonePlan, ZoneState, RemoveZone, SelectNet,@@ -230,10 +253,12 @@ fn live(state: &mut State, args: &Value, which: Live) -> Value { let result = match which { Live::State => ipc::routing_state(&ctx, args), Live::TextBounds => ipc::text_bounds(&ctx,args),+ Live::SilkTextBatch => ipc::silk_text_batch(&ctx,args), Live::RouteNet => ipc::route_net(&ctx, args), Live::RemoveRoute => ipc::remove_route(&ctx, args), Live::Validate => ipc::routing_validate(&ctx, args), Live::AddZone => ipc::add_zone(&ctx, args),+ Live::ApplyZonePlan => ipc::apply_zone_plan(&ctx,args), Live::ZoneState => ipc::zone_state(&ctx, args), Live::RemoveZone => ipc::remove_zone(&ctx, args), Live::SelectNet => ipc::select_net(&ctx, args),
rust/crates/kicad-core/src/ipc.rs+89−2@@ -259,7 +259,8 @@ fn drc_in(exe: &Path, source: &Path, text: &str, tmp: &Path) -> RResult<Value> { let mut copied: Vec<String> = Vec::new(); for ext in ["kicad_pro", "kicad_dru"] { let sibling = source.with_extension(ext);- if sibling.exists() && std::fs::copy(&sibling, candidate.with_extension(ext)).is_ok() {+ if sibling.exists() {+ std::fs::copy(&sibling, candidate.with_extension(ext)).map_err(|e|RoutingError::new("drc_rules_copy_failed",format!("Cannot preserve project rules {}: {e}",sibling.display())).with("mutated",json!(false)))?; copied.push(sibling.to_string_lossy().to_string()); } }@@ -905,6 +906,51 @@ pub fn add_zone(ctx: &Ctx, args: &Value) -> RResult<Value> { Ok(result) } +/// Reconcile only explicitly owned zones in one native undo transaction. Always DRC+/// the complete candidate first; preserve unrelated zones and require a revision.+pub fn apply_zone_plan(ctx: &Ctx, args: &Value) -> RResult<Value> {+ if args["expectedRevision"].as_str().filter(|s|!s.is_empty()).is_none() {+ return Err(RoutingError::new("revision_required","Read kicad_zone_state first and pass its expectedRevision"));+ }+ let s=connect(ctx,args)?;+ let (text,board,revision)=s.snapshot()?;check_revision(args,&revision)?;+ let plan=crate::zone_plan::prepare(&text,&board,args)?;+ let before=drc_snapshot(ctx.kicad_cli.as_deref(),&s.source_path(),&text)?;+ let check=drc_snapshot(ctx.kicad_cli.as_deref(),&s.source_path(),&plan.candidate)?;+ let added=new_errors(&before,&check)?;+ if !added.is_empty(){return Err(RoutingError::new("drc_rejected","Full zone plan adds native DRC errors; nothing changed").with("violations",json!(added)).with("mutated",json!(false)));}+ if args["dryRun"]==true || (plan.remove.is_empty() && plan.create.is_empty()) {+ return Ok(json!({"success":true,"mutated":false,"dryRun":args["dryRun"]==true,"revision":revision,"unchangedIds":plan.unchanged,"wouldRemoveIds":plan.remove,"wouldCreate":plan.create.iter().map(zones::ZoneSpec::public).collect::<Vec<_>>(),"drc":check,"undoSteps":0}));+ }+ let nets=s.client.get_nets().map_err(map_err)?;let mut items=Vec::new();+ for z in &plan.create {+ let net_code=if z.keepout.is_some(){0}else{nets.iter().find(|n|n.name==z.net.name).ok_or_else(||RoutingError::new("unknown_net",format!("No native net {:?}",z.net.name)))?.code};+ let layers=z.layers.iter().map(|l|BoardLayerInfo::id_from_name(l).ok_or_else(||RoutingError::new("unknown_layer",l.clone()))).collect::<RResult<Vec<_>>>()?;+ items.push(prost_types::Any{type_url:zones::ZONE_TYPE_URL.into(),value:zones::zone_bytes(z,&layers,net_code)});+ }+ check_revision(args,&s.revision()?)?;+ let created=transaction(&s,"Adom apply zone plan",||{+ if !plan.remove.is_empty(){+ s.client.delete_items(plan.remove.clone())?;+ // KiCad versions may return no DeleteItems rows. Verify actual item+ // absence within the transaction instead of trusting a row count.+ let remaining=s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code])?;+ if remaining.iter().filter_map(|a|zones::zone_id(&a.value)).any(|id|plan.remove.contains(&id)){return Err(IpcFailure::from("Owned zones remain after deletion; rollback requested".to_string()));}+ }+ let created=if items.is_empty(){Vec::new()}else{s.client.create_items(items.clone(),None)?};+ if created.len()!=items.len(){return Err(IpcFailure::from("Not every planned zone was created; rollback requested".to_string()));}+ Ok(created)+ })?;+ let ids:Vec<String>=created.iter().filter_map(|a|zones::zone_id(&a.value)).collect();+ let mut owned=plan.unchanged.clone();owned.extend(ids.clone());+ let mut out=json!({"success":true,"committed":true,"mutated":true,"undoSteps":1,"itemIds":ids,"ownedItemIds":owned,"unchangedIds":plan.unchanged,"removedIds":plan.remove,"drc":check,"saved":false,"refilled":false,"_hint":"Full plan committed as one native Undo step. Inspect postCommitError and kicad_zone_state before retrying. Ownership is only the returned ownedItemIds; never infer it from a zone name or age."});+ if ids.len()!=plan.create.len(){out["postCommitError"]=json!("Native creation returned incomplete zone IDs; inspect the board before any retry");}+ if args["refill"]!=false {match s.client.refill_zones(Vec::new()){Ok(())=>out["refilled"]=json!(true),Err(e)=>out["postCommitError"]=json!(e.to_string())}}+ match s.revision(){Ok(r)=>out["revision"]=json!(r),Err(e)=>out["postCommitError"]=json!(e.message)}+ if args["save"]==true {match s.client.save_document(){Ok(())=>out["saved"]=json!(true),Err(e)=>out["postCommitError"]=json!(e.to_string())}}+ Ok(out)+}+ /// `kicad_remove_zone`: delete zones (copper pours or keepouts) by id as one Undo step. Only /// zone items are accepted, so a pad, track or footprint id is refused with nothing removed. pub fn remove_zone(ctx: &Ctx, args: &Value) -> RResult<Value> {@@ -959,7 +1005,7 @@ pub fn zone_state(ctx: &Ctx, args: &Value) -> RResult<Value> { s.client.refill_zones(Vec::new()).map_err(map_err)?; refilled = true; }- let (_text, data, revision) = s.snapshot()?;+ let (text, data, revision) = s.snapshot()?; let raws = s.client.get_items_raw_by_type_codes(vec![PcbObjectTypeCode::new_zone().code]).map_err(map_err)?; let mut read = Vec::with_capacity(raws.len()); for a in &raws {@@ -971,6 +1017,7 @@ pub fn zone_state(ctx: &Ctx, args: &Value) -> RResult<Value> { "_hint": "filledAreaByLayerMm2 is the copper KiCad's zones keep on each layer: for a copper-ablation process, what does not have to be milled away. Add pours with kicad_add_zone; kicad_routing_validate is the DRC.", }); merge(&mut out, &zones::state_json(&read, &data));+ out["definitions"] = crate::zone_plan::readback(&text).map_err(|e|RoutingError::new("zone_undecodable",e))?; Ok(out) } @@ -1610,3 +1657,43 @@ pub fn text_bounds(ctx:&Ctx,args:&Value)->RResult<Value>{ } } +/// Update standalone silk text by stable UUID, in one native Undo transaction.+pub fn silk_text_batch(ctx:&Ctx,args:&Value)->RResult<Value>{+ use kicad_ipc_rs::BoardTextItem;+ let s=connect(ctx,args)?;let (text,_,revision)=s.snapshot()?;check_revision(args,&revision)?;+ let plan=crate::silk_text::prepare(&text,args)?;+ // Silkscreen clearance findings are often warnings. Compare all severities, not only errors.+ let all_severities=|mut v:Value| {if let Some(rows)=v["violations"].as_array_mut(){for row in rows {row["severity"]=json!("error");}}v};+ let before=drc_snapshot(ctx.kicad_cli.as_deref(),&s.source_path(),&text)?;+ let after=drc_snapshot(ctx.kicad_cli.as_deref(),&s.source_path(),&plan.candidate)?;+ let added=new_errors(&all_severities(before),&all_severities(after.clone()))?;+ if !added.is_empty(){return Err(RoutingError::new("drc_rejected","Silkscreen candidate adds native DRC findings; no live text changed").with("violations",json!(added)).with("mutated",json!(false)));}+ if args["dryRun"]==true{return Ok(json!({"success":true,"mutated":false,"dryRun":true,"revision":revision,"drc":after}));}+ let item=|t:&crate::silk_text::Text| {+ let mut item=BoardTextItem::from_proto(Default::default());item.set_layer_id(BoardLayerInfo::id_from_name(&t.layer).unwrap());+ let p=item.proto_mut();p.id=Some(Default::default());p.id.as_mut().unwrap().value=t.id.clone();p.locked=1;p.knockout=false;+ p.text=Some(Default::default());let text=p.text.as_mut().unwrap();text.text=t.text.clone();text.position=Some(Default::default());let pos=text.position.as_mut().unwrap();pos.x_nm=nm(t.x);pos.y_nm=nm(t.y);+ text.attributes=Some(Default::default());let a=text.attributes.as_mut().unwrap();a.horizontal_alignment=match t.align.as_str(){"left"=>1,"right"=>3,_=>2};a.vertical_alignment=2;+ a.angle=Some(Default::default());a.angle.as_mut().unwrap().value_degrees=t.rotation;+ a.line_spacing=1.;a.stroke_width=Some(Default::default());a.stroke_width.as_mut().unwrap().value_nm=nm(t.stroke);+ a.size=Some(Default::default());a.size.as_mut().unwrap().x_nm=nm(t.width);a.size.as_mut().unwrap().y_nm=nm(t.height);+ a.mirrored=t.mirrored;a.multiline=t.text.contains('\n');a.visible=true;+ EditablePcbItem::BoardText(item)+ };+ let created_ids:Vec<String>=plan.create.iter().map(|t|t.id.clone()).collect();let updated_ids:Vec<String>=plan.update.iter().map(|t|t.id.clone()).collect();+ let validate_reply=|actual:Vec<EditablePcbItem>,wanted:&[String]|->Result<(),IpcFailure>{let actual:HashSet<_>=actual.iter().filter_map(|t|t.id().map(str::to_string)).collect();if actual!=wanted.iter().cloned().collect(){return Err(IpcFailure::from("KiCad reply did not preserve every requested text UUID".to_string()));}Ok(())};+ check_revision(args,&s.revision()?)?;+ transaction(&s,"Adom silkscreen text",||{+ if !plan.remove.is_empty(){s.client.delete_items(plan.remove.clone())?;let remaining=s.client.get_items_by_type_codes(vec![PcbObjectTypeCode::new_text().code])?;if remaining.iter().filter_map(item_id).any(|id|plan.remove.iter().any(|x|x==id)){return Err(IpcFailure::from("KiCad still reports deleted text".to_string()));}}+ if !plan.update.is_empty(){validate_reply(s.client.update_editable_items(plan.update.iter().map(&item).collect())?,&updated_ids)?;}+ if !plan.create.is_empty(){validate_reply(s.client.create_editable_items(plan.create.iter().map(&item).collect(),None)?,&created_ids)?;}+ Ok(())+ })?;+ let mut result=json!({"success":true,"mutated":true,"committed":true,"undoSteps":1,"createdIds":created_ids,"updatedIds":updated_ids,"removedIds":plan.remove,"drc":after,"saved":false,"source":"live-editor","_hint":"One native Undo step. Updates require full text definitions and preserve UUIDs; no content-based replacement. Inspect returned revision/bounds and refresh the linked 3D viewer. A postCommitError means committed: inspect state, never blindly replay."});+ match s.revision(){Ok(rev)=>result["revision"]=json!(rev),Err(e)=>result["postCommitError"]=json!(e.message)}+ let ids:Vec<_>=created_ids.iter().chain(&updated_ids).cloned().collect();+ match s.client.get_item_bounding_boxes(ids,false){Ok(boxes)=>result["bounds"]=json!(boxes.iter().map(|b|json!({"id":b.item_id,"boxMm":[b.x_nm as f64/1e6,b.y_nm as f64/1e6,(b.x_nm+b.width_nm) as f64/1e6,(b.y_nm+b.height_nm) as f64/1e6]})).collect::<Vec<_>>()),Err(e)=>result["postCommitBoundsError"]=json!(e.to_string())}+ if args["save"]==true {match s.client.save_document(){Ok(())=>result["saved"]=json!(true),Err(e)=>result["postCommitSaveError"]=json!(e.to_string())}}+ Ok(result)+}+
rust/crates/kicad-core/src/lib.rs+4@@ -28,3 +28,7 @@ pub mod install_kicad; pub mod demo; pub mod dsn; pub mod freerouting;++pub mod silk_text;+pub mod zone_plan;+
rust/crates/kicad-core/src/model_check.rs+24@@ -646,6 +646,7 @@ pub fn check_board(ctx: &Ctx, board_path: &str, limit: usize) -> Value { }; let project_dir = p.parent().map(norm).unwrap_or_default(); let mut per = Vec::new();+ let mut bindings = Vec::new(); let (mut footprints, mut with_model, mut no_model_expected_n, mut missing, mut unresolved, mut nwarn) = (0u64, 0u64, 0u64, 0u64, 0u64, 0u64); for fp in tree.find_all("footprint") { let fp_id = fp.atom_at(1).unwrap_or("?").to_string();@@ -659,6 +660,14 @@ pub fn check_board(ctx: &Ctx, board_path: &str, limit: usize) -> Value { } let rows = model_rows(fp, ctx, Some(&project_dir)); let a = attrs(fp);+ bindings.push(json!({+ "itemId": fp.find("uuid").and_then(|x| x.atom_at(1)),+ "reference": reference,+ "footprint": fp_id,+ "layer": fp.find("layer").and_then(|x| x.atom_at(1)),+ "models": rows,+ "noModelExpected": rows.is_empty() && no_model_expected(&fp_id, &a),+ })); footprints += 1; let mut w = tag_warnings(&rows); if !rows.is_empty() {@@ -689,6 +698,10 @@ pub fn check_board(ctx: &Ctx, board_path: &str, limit: usize) -> Value { "counts": {"footprints": footprints, "withModel": with_model, "noModelExpected": no_model_expected_n, "missingModel": missing, "unresolvedModel": unresolved, "warnings": nwarn}, "footprintsWithWarnings": per,+ "modelBindings": bindings,+ "bindingsComplete": true,+ "evidenceSource": "saved-board-file",+ "unsavedEditorStateIncluded": false, "ok": nwarn == 0, "renderVerified": false, "nextCheck": "kicad_show_3d_board and read the frame: bodies on every populated footprint are the proof.",@@ -897,6 +910,16 @@ mod tests { let r = check_board(&f.ctx, &norm(&board), 1); assert_eq!(r["footprintsWithWarnings"].as_array().unwrap().len(), 1); assert_eq!(r["counts"]["warnings"], json!(2));+ // Warning limit must never hide successful bindings or model-free items.+ let bindings = r["modelBindings"].as_array().unwrap();+ assert_eq!(bindings.len(), 4);+ assert_eq!(bindings[0]["reference"], "U1");+ assert_eq!(bindings[0]["layer"], "F.Cu");+ assert_eq!(bindings[0]["models"][0]["exists"], true);+ assert_eq!(bindings[2]["noModelExpected"], true);+ assert_eq!(bindings[3]["models"], json!([]));+ assert_eq!(r["bindingsComplete"], true);+ assert_eq!(r["unsavedEditorStateIncluded"], false); } #[test]@@ -926,3 +949,4 @@ mod tests { assert_eq!(ctx.expand("${KICAD10_3RD_PARTY}/x.step", None).0, "/custom/3rd/x.step"); } }+