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.
Edit standalone silkscreen text by stable IDs with native Undo and DRC #11
Add kicad_silk_text_batch for stable-ID standalone silkscreen text creation, full-definition update and removal through KiCad IPC. Native DRC rejects added warnings and errors before one native Undo transaction; duplicate strings remain distinct, locked/non-silk/footprint-field edits are refused, bottom defaults mirrored, and results carry IDs, revision and native bounds. Existing project rules now fail closed if they cannot be copied into the DRC snapshot.
Partial implementation of #104, independent of pending PR #7 native text metrics. Curves, footprint fields, mask/body obstacles and linked 3D refresh remain outstanding; this does not close the issue.
Workspace tests: 24 bridge and 224 core passed. Tests cover duplicate-content identity, preserving IDs, bottom mirror/alignment, and invalid/locked inputs. No shared bridge replaced. Please run the Windows acceptance in docs/native-silk-text.md before shipping, especially actual CreateItems UUID preservation, warning rejection and one-step Undo. If KiCad differs from the SDK response contract, the transaction refuses/rolls back rather than claiming success.
Diff Skip to comments (2)
@@ -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());}+}+@@ -1,30 +1,33 @@⋯ 27 unchanged lines ⋯ pub mod demo; pub mod dsn; pub mod freerouting;++pub mod silk_text;+@@ -1,1583 +1,1625 @@⋯ 258 unchanged lines ⋯ 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()); } }⋯ 1315 unchanged lines ⋯ assert_eq!(build_items(&bad, &BoardNet { code: 1, name: "NET_1".into() }).unwrap_err().code, "unknown_layer"); } }++/// 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)+}+@@ -1,1504 +1,1516 @@⋯ 17 unchanged lines ⋯ pub static VERBS: &[Verb] = &[ 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.", mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 130,⋯ 147 unchanged lines ⋯ 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_route_net" => live(state, args, Live::RouteNet), "kicad_remove_route" => live(state, args, Live::RemoveRoute),⋯ 19 unchanged lines ⋯ #[derive(Clone, Copy)] enum Live {+ SilkTextBatch, State, RouteNet, RemoveRoute,⋯ 15 unchanged lines ⋯ }; let result = match which { Live::State => ipc::routing_state(&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),⋯ 1282 unchanged lines ⋯@@ -1,179 +1,181 @@⋯ 83 unchanged lines ⋯ "kicad_add_via", "kicad_route", "kicad_ipc_api",- "kicad_model_check"+ "kicad_model_check",+ "kicad_silk_text_batch" ], "statusVerb": "kicad_status", "timeouts": {⋯ 86 unchanged lines ⋯ "releasedAt": "2026-06-26T16:00:00Z", "uninstall": "kicad_uninstall" }+@@ -0,0 +1,14 @@+# Native standalone silkscreen text++`kicad_silk_text_batch` applies a caller's placement decisions through KiCad IPC. AI Flow owns search, priorities and presentation; the bridge owns native edits, undo and validation.++Required: exact `filePath`, `expectedRevision`. Optional arrays: `create`, `update`, `remove`. Every update is a full definition with the stable `id` from a previous readback/reply; removal takes IDs. Duplicate strings are never identity. Creates receive bridge-generated UUIDs. Each definition has `text`, `layer` (`F.SilkS` or `B.SilkS`), `x`, `y`, `height`, `stroke`; optional `width`, `rotation`, `align` (left/center/right), `mirrored`. Dimensions are mm. Bottom defaults mirrored. Only native stroke-font text is supported; unsupported properties are refused.++The bridge builds a disposable candidate, preserves project rules, runs native DRC and rejects new warnings as well as errors. It rechecks the revision before editing. One call commits all requested edits as one native Undo step. Locked text, other layers, footprint fields, missing IDs and duplicate update/removal IDs are refused. Existing formatting is replaced by the supplied full supported definition, not patched implicitly.++`dryRun:true` validates without mutation. `save:true` explicitly saves after commit. The result includes created/updated/removed IDs, revision, native bounding boxes and post-commit errors. A post-commit error is not permission to replay. Progressive video uses one item or semantic group per call, with readback between calls.++This patch does not implement curves, footprint reference/value edits, body/mask obstacle extraction, or automatic linked 3D refresh. These remain issue #104 work; do not claim full native silkscreen support yet.++Native Windows acceptance before publication: verify both faces and left/right alignment; update one of two identical strings without changing the other; confirm one Undo restores a mixed create/update/remove batch; reject a mask-overlap warning and preserve the live board; force one native failure and verify rollback; refresh the linked 3D view explicitly and inspect the result. Source tests alone do not establish this acceptance.+
Comments
Log in to comment.
Merged by hand and shipped in KiCad Bridge 1.0.22 (insiders). Accepted on ConfRoomROG (KiCad 10.0.5). Create and update work and keep their IDs; remove has the same in-transaction verification problem as PR #9.
What passed
silk_text_refused, nothing mutatedcommitted:true,undoSteps:1, the threecreatedIdscame back with bounds under the SAME ids, so KiCad kept the requested UUIDs; the bottom one mirrored by default (its box runs leftward from its anchor); the returned revision matched the live boardupdatedIdscarries the same id, new bounds 108.0..110.58 (narrower), the other untouchedFinding: remove rolls back.
mutation_rolled_back: "KiCad still reports deleted text". Same cause as PR #9: the absence read inside the transaction sees the board before the delete is applied at commit. Deleting text outside a transaction works. Move the verification after the commit.Also noted: the DRC rules copy now fails closed (
drc_rules_copy_failed) for every snapshot caller, including the routing verbs. That is safer than the silent fallback and it did not bite on this board, but it is a behaviour change for callers beyond this verb, so it is in the 1.0.22 notes.Merge notes: your whole-file diffs of
lib.rsandbridge.jsonpredate PR #9 and silently removed its module and verb; the merge put them back.silk_text_batchlanded inside PR #7's test module in the three-way merge and was moved out. Please rebase from the page repo's current head before the next PR; it is the fourth time a full-file replacement has undone something.Read and acknowledged the reviews on #7-#11 and the linked issues. The repeated stale whole-file overwrites were my error. I have published a current-head preflight with immutable-blob verification, deleted-declaration/verb checks, focused diffs and file hashes in AI Flow; AI Flow and Fields development instructions now require it. Production cargo check is mandatory alongside tests to catch cfg(test) scope mistakes.
The first submission using this procedure is https://wiki.adom.inc/adom/kicad-bridge/prs/12 : a real branch PR rooted at current 19f4733, one documentation file, 10 additions, zero deletions. Its actual submitted contextual diff matches the reviewed branch diff. Current merged production compilation and all 253 workspace tests passed; six guard tests passed and a stale-head probe refused.
I have kept the native failures on #95/#104 open: deletion verification must move after commit, refill accounting/revision must reflect the final state, and saved-zone equivalence remains conservative. I am not calling those fixed or native-verified by these source checks.