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
Merge PR #453: 1.0.106: silk batch verbs take kicad_silk_state's y-up frame; off-board text refused (closes #141)
7 files changed
+67−15
rust/Cargo.lock+3−3@@ -319,7 +319,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.105"+version = "1.0.106" dependencies = [ "adom-eda-checks", "kicad-core",@@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.105"+version = "1.0.106" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -358,7 +358,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.105"+version = "1.0.106" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.105"+version = "1.0.106" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/main.rs+1−1@@ -74,7 +74,7 @@ pub fn caller_side(code: &str) -> bool { const EXACT: &[&str] = &["bad_arg", "bad_args", "file_not_found", "unsaved_changes", "stale_board", "revision_required", "drc_rejected", "no_model", "reason_required", "not_supported", "unsupported_platform", "unknown_verb", "unknown_layer", "unknown_rule", "unknown_key", "unknown_action", "wrong_file_type", "file_exists", "footprint_exists", "ambiguous", "ambiguous_board", "already_there", "already_flagged",- "needs_closed_board", "documents_in_use", "live_required", "board_open", "kicad_running", "project_open", "too_close", "same_point", "zero_length", "plan_invalid",+ "needs_closed_board", "documents_in_use", "text_off_board", "live_required", "board_open", "kicad_running", "project_open", "too_close", "same_point", "zero_length", "plan_invalid", "invalid_path", "invalid_scope", "name_required", "engine_required", "foreground_required", "nothing_to_route", "nothing_in_box", "nothing_placed", "side_change_not_supported", "live_move_drops_units", "entry_off_bus", "bad_via_geometry"]; EXACT.contains(&code) || code.starts_with("missing_") || code.starts_with("no_such_") || code.ends_with("_not_found")
rust/crates/kicad-bridge/src/verbs_routing.rs+6−6@@ -21,9 +21,9 @@ pub static VERBS: &[Verb] = &[ name: "kicad_silk_fields_batch", summary: "Update native footprint reference/value field geometry by stable field UUID.", mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,- input: "{filePath,expectedRevision,update:[{id,x,y,rotation?,layer,height,width?,stroke,align?,mirrored?,visible?}],dryRun?,save?,allowUnitsDrop?:false}",+ input: "{filePath,expectedRevision,update:[{id,x,y,rotation?,layer,height,width?,stroke,align?,mirrored?,visible?}],dryRun?,save?,allowUnitsDrop?:false,frame?:\"kicad\"|\"standard\"}", example: "kicad_silk_fields_batch {filePath,expectedRevision,update:[{id,x:10,y:20,layer:F.SilkS,height:0.8,stroke:0.12}]}",- hint: "Board-frame mm on both faces. Preserves reference/value content, uses nested field updates, never replaces the whole footprint. Full candidate DRC includes warnings. One Undo, then field and unrelated-board-data readback; inspect postCommit errors before retrying.",+ hint: "Board-frame mm on both faces, KiCad's y down; frame:\"standard\" takes kicad_silk_state's default y-up positions. Preserves reference/value content, uses nested field updates, never replaces the whole footprint. Full candidate DRC includes warnings. One Undo, then field and unrelated-board-data readback; inspect postCommit errors before retrying.", related: &["kicad_text_bounds", "kicad_silk_text_batch", "kicad_refresh_board_views"], pitfalls: &["KiCad 10.0.x moves a field only through its whole footprint and drops the footprint's units block (pin groups) doing so: such a footprint refuses with field_move_drops_units, nothing changed, unless allowUnitsDrop:true. kicad_silk_designators writes labels into the closed board instead.", "Only plain stroke-font Reference/Value properties. No field creation/removal or content changes. Refuses if native field IDs cannot be queried. Exact masks and 3D body obstacles remain separate."], },@@ -31,9 +31,9 @@ pub static VERBS: &[Verb] = &[ name: "kicad_silk_graphics_batch", summary: "Create/update/remove native silkscreen lines, borders, arcs and Bezier leaders by stable ID.", mechanism: Mechanism::Ipc, risk: "write", timeout_sec: 130,- input: "{filePath,expectedRevision,create:[{kind,layer,points,stroke}],update:[{id,kind,layer,points,stroke}],remove:[id],dryRun?,save?}",+ input: "{filePath,expectedRevision,create:[{kind,layer,points,stroke}],update:[{id,kind,layer,points,stroke}],remove:[id],dryRun?,save?,frame?:\"kicad\"|\"standard\"}", example: "kicad_silk_graphics_batch {filePath,expectedRevision,create:[{kind:line,layer:F.SilkS,points:[[1,2],[3,4]],stroke:0.15}]}",- hint: "Coordinates are board-frame mm on either side. kind is line/rect (2 points), arc (start/mid/end), bezier (start/control1/control2/end). Full update definitions preserve UUID. Solid stroke, unfilled shapes; locked/non-silk IDs refuse. Native DRC includes warnings; one Undo step.",+ hint: "Coordinates are board-frame mm on either side, KiCad's y down; frame:\"standard\" takes kicad_silk_state's default y-up points. kind is line/rect (2 points), arc (start/mid/end), bezier (start/control1/control2/end). Full update definitions preserve UUID. Solid stroke, unfilled shapes; locked/non-silk IDs refuse. Native DRC includes warnings; one Undo step.", related: &["kicad_silk_text_batch", "kicad_text_bounds"], pitfalls: &["No footprint graphics or implicit text-box obstacles; these are manufactured silk, not dashboard ghost rectangles.","Inspect postCommit errors before replay; source tests do not establish native Windows acceptance."], },@@ -50,9 +50,9 @@ 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}",+ input: "{filePath,expectedRevision,create?:[text definitions],update?:[full text definitions with id],remove?:[ids],dryRun?:true,save?:false,frame?:\"kicad\"|\"standard\",allowOffBoard?: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.",+ hint: "Definitions: text, layer F.SilkS/B.SilkS, x/y/height/stroke in mm, KiCad's y down (frame:\"standard\" takes kicad_silk_state's default y-up positions). A position outside the board outline refuses with text_off_board unless allowOffBoard:true; 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."], },
rust/crates/kicad-bridge/src/verbs_standard.rs+4−2@@ -178,7 +178,7 @@ pub static VERBS: &[Verb] = &[ mechanism: Mechanism::File, risk: "read", timeout_sec: 60, input: "{\"filePath\"?: \"C:/.../x.kicad_pcb\", \"source\"?: \"auto|live|file\", \"frame\"?: \"standard|kicad\"}", example: "kicad_silk_state {\"filePath\":\"C:/Users/john/proj/board.kicad_pcb\"}",- hint: "mm in the standard's frame (y up; frame:\"kicad\" for y down). bodyBox comes from the part's fab drawing (bodyFrom says when it fell back to the courtyard). The ids feed kicad_silk_fields_batch, kicad_silk_text_batch and kicad_silk_graphics_batch.",+ hint: "mm in the standard's frame (y up; frame:\"kicad\" for y down). Send these positions back to the silk batch verbs with frame:\"standard\": they take KiCad's y-down frame by default. bodyBox comes from the part's fab drawing (bodyFrom says when it fell back to the courtyard). The ids feed kicad_silk_fields_batch, kicad_silk_text_batch and kicad_silk_graphics_batch.", related: &["kicad_silk_fields_batch", "kicad_silk_text_batch", "kicad_silk_graphics_batch", "kicad_text_bounds"], pitfalls: &["Text sizes are the font size, not the measured ink: kicad_text_bounds measures text as KiCad draws it."], },@@ -769,7 +769,9 @@ fn silk(state: &mut State, args: &Value) -> Value { Ok(mut v) => { stamp(&mut v, &r); v["output"] = json!(format!("{} parts, {} silkscreen texts, {} silkscreen graphics", v["counts"]["parts"], v["counts"]["texts"], v["counts"]["graphics"]));- v["_hint"] = json!(format!("{}Move fields with kicad_silk_fields_batch (by designator.id), free text with kicad_silk_text_batch, lines with kicad_silk_graphics_batch.", file_note(&r)));+ // wiki #141: the batch verbs take KiCad's frame unless told otherwise+ let send_back = if o.y_up { "Positions here are y up (frame standard): pass frame:\"standard\" with them to the batch verbs, which otherwise take KiCad's y-down frame." } else { "Positions here are KiCad's y-down frame, which the batch verbs take by default." };+ v["_hint"] = json!(format!("{}Move fields with kicad_silk_fields_batch (by designator.id), free text with kicad_silk_text_batch, lines with kicad_silk_graphics_batch. {send_back}", file_note(&r))); v } Err(e) => fail("board_unparsable", e, "kicad_lint_board gets KiCad's own verdict on the file."),
rust/crates/kicad-core/src/board_model.rs+21−2@@ -1188,7 +1188,9 @@ fn natural_key(s: &str) -> (String, u64, String) { // -- the edit verbs in the standard's frame ---------------------------------------------------- /// The edit verbs whose coordinates `frame:"standard"` turns from y-up into KiCad's y-down.-pub const FRAME_VERBS: &[&str] = &["kicad_route_net", "kicad_move_footprint", "kicad_add_zone", "kicad_apply_zone_plan", "kicad_add_via", "kicad_add_track", "kicad_route"];+pub const FRAME_VERBS: &[&str] = &["kicad_route_net", "kicad_move_footprint", "kicad_add_zone", "kicad_apply_zone_plan", "kicad_add_via", "kicad_add_track", "kicad_route",+ // wiki #141: kicad_silk_state reports y up by default, so its positions go back with frame:"standard"+ "kicad_silk_text_batch", "kicad_silk_fields_batch", "kicad_silk_graphics_batch"]; fn flip_point(v: &mut Value) { match v {@@ -1239,11 +1241,13 @@ pub fn args_to_kicad_frame(args: &Value) -> Result<Value, String> { } } flip_point(&mut a);- for k in ["refs", "zones"] {+ for k in ["refs", "zones", "create", "update"] { if let Some(Value::Array(items)) = a.get_mut(k) { for it in items.iter_mut() { flip_point(it); flip_list(it.get_mut("polygon"));+ // the silk graphics rows (wiki #141)+ flip_list(it.get_mut("points")); } } }@@ -1898,6 +1902,21 @@ mod tests { assert!(args_to_kicad_frame(&json!({"frame": "metric"})).is_err()); } + /// Wiki #141: a text's position from kicad_silk_state (y up) goes back to the silk batch verbs as is.+ #[test]+ fn silk_batch_rows_in_the_standard_frame_turn_into_kicads() {+ assert!(FRAME_VERBS.contains(&"kicad_silk_text_batch") && FRAME_VERBS.contains(&"kicad_silk_fields_batch") && FRAME_VERBS.contains(&"kicad_silk_graphics_batch"));+ let a = args_to_kicad_frame(&json!({"frame": "standard", "expectedRevision": "r",+ "update": [{"id": "t", "x": 120.0, "y": -135.92, "rotation": 90.0}, {"id": "g", "kind": "line", "points": [[1.0, -2.0], [3.0, -4.0]]}],+ "create": [{"text": "J1", "x": 1.0, "y": -5.0}], "remove": ["x"]})).unwrap();+ assert_eq!(a["update"][0]["y"], json!(135.92));+ assert_eq!(a["update"][0]["rotation"], json!(90.0));+ assert_eq!(a["update"][1]["points"], json!([[1.0, 2.0], [3.0, 4.0]]));+ assert_eq!(a["create"][0]["y"], json!(5.0));+ assert_eq!(a["remove"], json!(["x"]));+ assert_eq!(a["_fromFrame"], json!("standard"));+ }+ #[test] fn core_reads_are_views_of_the_model() { let m = std_model(&format!("{RECT_OUTLINE}(footprint \"lib:R_0603\" (layer \"F.Cu\") (at 10 10) (property \"Reference\" \"R10\") (property \"Value\" \"10k\")\
rust/crates/kicad-core/src/silk_text.rs+31@@ -38,9 +38,29 @@ pub fn prepare(text:&str,args:&Value)->Result<Plan,RoutingError>{ 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"));}+ if args.get("allowOffBoard").and_then(Value::as_bool)!=Some(true){off_board(text,args,create.iter().chain(&update))?;} 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}) }+/// Wiki #141: a text whose anchor lands outside the board outline is refused (errorCode text_off_board,+/// nothing changed) unless allowOffBoard:true. KiCad's DRC says nothing about silkscreen that lies wholly+/// off the board, so without this a y-up position from kicad_silk_state sent back in KiCad's y-down frame+/// moved the text off the board with success. When the mirrored y would land on the board, the refusal+/// says so and names frame:"standard".+fn off_board<'a>(text:&str,args:&Value,rows:impl Iterator<Item=&'a Text>)->Result<(),RoutingError>{+ let Some(d)=crate::pcb::parse_pcb_text(text,"").ok().and_then(|b|b.dimensions) else {return Ok(())};+ let inside=|x:f64,y:f64|x>=d.min_x-1e-6&&x<=d.max_x+1e-6&&y>=d.min_y-1e-6&&y<=d.max_y+1e-6;+ let off:Vec<&Text>=rows.filter(|t|!inside(t.x,t.y)).collect();+ if off.is_empty(){return Ok(());}+ let given_up=args.get("_fromFrame").and_then(Value::as_str)==Some("standard");+ let mirrored=!given_up&&off.iter().all(|t|t.y<0.0&&inside(t.x,-t.y));+ let hint=if mirrored{"These y values are negative and their mirror (-y) is on the board: they look like kicad_silk_state's y-up (standard frame) positions. Pass frame:\"standard\" with them, or read kicad_silk_state with frame:\"kicad\"."}+ else{"Give a position inside the board outline (outline below, KiCad's y-down frame unless you pass frame:\"standard\"), or allowOffBoard:true if the text really belongs off the board."};+ Err(RoutingError::new("text_off_board",format!("{} text position(s) lie outside the board outline; nothing changed",off.len()))+ .with("mutated",json!(false)).with("_hint",json!(hint)).with("looksLikeStandardFrame",json!(mirrored))+ .with("outline",json!({"minX":d.min_x,"minY":d.min_y,"maxX":d.max_x,"maxY":d.max_y}))+ .with("offBoard",json!(off.iter().map(|t|json!({"id":t.id,"text":t.text,"x":t.x,"y":t.y})).collect::<Vec<_>>())))+} #[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)))";@@ -48,6 +68,17 @@ pub fn prepare(text:&str,args:&Value)->Result<Plan,RoutingError>{ 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);}+ /// Wiki #141: kicad_silk_state's y-up y sent back in KiCad's frame lands off the board and is refused.+ #[test]fn a_text_off_the_board_is_refused_and_a_mirrored_y_is_named(){+ let text="(kicad_pcb (gr_rect (start 100 100) (end 160 140) (layer \"Edge.Cuts\") (uuid e)) (gr_text \"J1\" (at 120 135.92) (layer \"F.SilkS\") (uuid t)))";+ let row=|y:f64|json!({"update":[{"id":"t","text":"J1","layer":"F.SilkS","x":120,"y":y,"height":1,"stroke":0.15}]});+ assert!(prepare(text,&row(135.92)).is_ok());+ let e=prepare(text,&row(-135.92)).err().unwrap();assert_eq!(e.code,"text_off_board");assert_eq!(e.detail["looksLikeStandardFrame"],true);assert_eq!(e.detail["mutated"],false);+ let far=prepare(text,&row(300.0)).err().unwrap();assert_eq!(far.code,"text_off_board");assert_eq!(far.detail["looksLikeStandardFrame"],false);+ let mut ok=row(300.0);ok["allowOffBoard"]=json!(true);assert!(prepare(text,&ok).is_ok());+ // a board with no outline has nothing to check against+ assert!(prepare("(kicad_pcb (gr_text \"J1\" (at 1 2) (layer \"F.SilkS\") (uuid t)))",&row(-5.0)).is_ok());+ } #[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());} }