KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
Reconcile explicitly owned zone plans in one native undo transaction #9
Add kicad_apply_zone_plan so AI Flow can delegate complete pour reconciliation to the native Bridge. Require exact board revision and explicit owned IDs, preserve all unrelated zones, refuse unowned/ambiguous name collisions, compare complete definitions rather than name/area, preflight the entire candidate with native DRC, and apply changes in one Undo transaction. Return committed/mutated, created/removed/unchanged IDs and the next ownership set. A later refill/read/save failure remains a postCommitError, never an invitation to duplicate the edit.
Zone state now exposes full configuration definitions, including holes/arcs and settings, separately from filled-area summaries. Desired holes/arcs are explicitly refused by this version's polygon input schema. Conservative comparison can replace a zone when native defaults differ; it cannot mistake moved equal-area geometry for unchanged. No DRC bypass is exposed by this new verb.
Built from fresh Bridge1.0.18 source independently of PR7. Workspace lib/bin tests pass, including explicit ownership, foreign-zone preservation, name conflicts, missing IDs, identical definitions, changed clearance, moved geometry/layer/keepout and ignored fill-cache/UUID identity. No shared runtime was replaced. Required owner acceptance before release: Windows dry-run/no-mutation, update/remove/add in one Undo step, rollback when DeleteItems/CreateItems fail, preservation of unrelated holes/keepouts, native refill and saved/unsaved behavior. The IPC deletion verification intentionally reads item absence because some KiCad versions return no DeleteItems rows; this must be exercised on the native build.
Addresses the native reconciliation portion of issues101/95 and AI Flow21. Model binding/appearance and native silkscreen primitives remain separate work. AI Flow0.1.29 already removed the unsafe ownership/area assumptions as a disclosed conservative fallback; once this verb is published and accepted, it can delegate directly.
Diff Skip to comments (1)
@@ -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()));+ }+ }+}+@@ -1,30 +1,32 @@⋯ 16 unchanged lines ⋯ pub mod pcb; pub mod placement; pub mod zones;+pub mod zone_plan; #[cfg(feature = "ipc")] pub mod ipc; pub mod uninstall;⋯ 5 unchanged lines ⋯ pub mod demo; pub mod dsn; pub mod freerouting;+@@ -1,1583 +1,1630 @@⋯ 904 unchanged lines ⋯ 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> {⋯ 48 unchanged lines ⋯ 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 {⋯ 5 unchanged lines ⋯ "_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) } ⋯ 604 unchanged lines ⋯ assert_eq!(build_items(&bad, &BoardNet { code: 1, name: "NET_1".into() }).unwrap_err().code, "unknown_layer"); } }+@@ -1,1504 +1,1517 @@⋯ 57 unchanged lines ⋯ 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.", mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 130,⋯ 112 unchanged lines ⋯ "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),⋯ 19 unchanged lines ⋯ RemoveRoute, Validate, AddZone,+ ApplyZonePlan, ZoneState, RemoveZone, SelectNet,⋯ 14 unchanged lines ⋯ 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),⋯ 1278 unchanged lines ⋯@@ -1,179 +1,181 @@⋯ 83 unchanged lines ⋯ "kicad_add_via", "kicad_route", "kicad_ipc_api",- "kicad_model_check"+ "kicad_model_check",+ "kicad_apply_zone_plan" ], "statusVerb": "kicad_status", "timeouts": {⋯ 86 unchanged lines ⋯ "releasedAt": "2026-06-26T16:00:00Z", "uninstall": "kicad_uninstall" }+@@ -1,74 +1,96 @@⋯ 71 unchanged lines ⋯ ## What to report Per layer: filled copper before and after, in mm2 and as a percentage of the board; per pour: net, layer, area, connection style; the DRC result; and every judgement call above that applied (which nets were deliberately not poured and why). A pour count with no areas is not a report.++## Reconcile a complete owned plan++`kicad_apply_zone_plan` takes the exact `filePath`, a required `expectedRevision`,+explicit `ownedItemIds` (empty on the first call), and `zones` with unique nonempty+names. It preserves every unrelated zone, refuses ambiguous or unowned name+collisions, compares full definitions instead of area, DRC-checks the entire+candidate, then applies removals and additions as one native Undo operation.+Use `dryRun:true` first. Record returned `ownedItemIds` for the next call; neither+a name nor absence from an earlier board proves ownership. `zones:[]` removes+only the explicitly owned IDs. `save:true` is separate and opt-in.++A successful commit can carry `postCommitError` if a later refill, read or save+fails. It still reports committed/mutated and its item IDs: inspect native state+before retrying. An ambiguous transaction outcome must never be replayed blindly.+`kicad_zone_state.definitions` carries the full configuration, including outline+holes/arcs and settings, separately from filled-area summaries. Desired holes+and arcs are refused by the current polygon request schema, not silently omitted.+Unknown or extra native defaults may conservatively cause replacement rather than+an unchanged result. Native Windows acceptance of this new verb is required before+calling it a deployed capability.+
Comments
Log in to comment.
Merged by hand and shipped in KiCad Bridge 1.0.22 (insiders). Accepted on ConfRoomROG (KiCad 10.0.5) against an unrouted ESC copy with two inner planes. Create works; update and remove do not yet, and the reason is precise.
What passed
ownedItemIds: []mutated:false,wouldCreate1, DRC reports the board's 13 pre-existing errors and no new oneszone_plan_refused: "ambiguous or belongs to an unowned zone", nothing mutatedcommitted:true, oneitemIds,ownedItemIdscarries it,refilled:true;kicad_zone_stateshows three zones and full definitions withdefinitionSexprTwo findings, both in the same place
mutation_rolled_back: "Owned zones remain after deletion; rollback requested", withrefillon or off. The deletion itself is fine: the shippedkicad_remove_zonedeleted the very same zone by the samedelete_itemscall andkicad_zone_stateconfirmed it gone. Inside the transaction,get_items_raw_by_type_codesstill returns the zone, because KiCad applies the delete at commit. Your comment anticipated the row-count problem; the absence read has the same problem one level down. The rollback works, so nothing is corrupted, but the update path cannot be used. Verify absence after the commit and report a remaining zone aspostCommitError, or trustdelete_itemsinside the transaction and verify after.refillis a second Undo step, and it moves the revision after the reply is written. Withrefill:truethe reply saysundoSteps:1but one Undo only removed the fill; the second removed the zone. Therevisionin that reply is the pre-refill one, so the very next call with it answersstale_board. Withrefill:falseboth are correct. Either reportundoSteps:2, or read the revision after the refill settles, or leave refilling to the caller.A third, smaller one: the "unchanged" comparison never fires for a zone KiCad wrote. Re-applying the identical plan against the owned id did not answer
unchangedIds; it went to remove-and-create (and then hit finding 1). KiCad's saved definition carries defaults the request does not, sodefinition()differs. Your PR text predicted this as the conservative outcome; on hardware it means every re-apply is a rewrite.Merge notes: three-way merged against 8c7206b (verbs_routing) and 87a8f65 (ipc) onto 1.0.21; one collision with PR #7's tail in
ipc.rs, resolved by keeping both. Yourbridge.jsonentry was later dropped by PR #11's older copy of the file and restored. Unit tests green (228 core).