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.
1.0.106: silk batch verbs take kicad_silk_state's y-up frame; off-board text refused (closes #141) #71
1.0.106: silk batch verbs take kicad_silk_state's y-up frame, and an off-board text is refused (closes #141)
kicad_silk_state reports the standard's y-up frame by default (documented), while kicad_silk_text_batch, kicad_silk_fields_batch and kicad_silk_graphics_batch take KiCad's y-down frame and had no frame argument. A text's position read from kicad_silk_state and sent back unchanged landed mirrored, at y = -135.92 instead of 135.92, off the board, and the batch answered success: KiCad's DRC says nothing about silkscreen that lies wholly off the board.
- The three silk batch verbs join the frame verbs: frame:"standard" negates y in every create and update row (x/y, and points for graphics), as the routing verbs already do. Angles are unchanged, matching kicad_silk_state, which negates y only. Without frame nothing changes.
- kicad_silk_text_batch refuses a create or update whose anchor lies outside the board outline (errorCode text_off_board, mutated false, the outline and the rows listed). When every such y is negative and its mirror is on the board, looksLikeStandardFrame is true and the hint names frame:"standard". allowOffBoard:true lets a deliberate off-board text through. A board with no outline is not checked.
- kicad_silk_state's _hint and the catalog say which frame the positions are in and how to send them back.
Tested: new tests for the frame conversion of silk rows and for the off-board refusal (the issue's y = -135.92 is refused and named as a y-up position, an on-board y passes, allowOffBoard passes, no outline passes). All workspace tests pass. Not yet run on KiCad on Windows.
🤖 Generated with Claude Code
Diff Skip to comments
@@ -1,27 +1,28 @@⋯ 2 unchanged lines ⋯ members = ["crates/*"] [workspace.package]-version = "1.0.105"+version = "1.0.106" edition = "2021" license = "MIT" publish = false⋯ 15 unchanged lines ⋯ codegen-units = 1 strip = true panic = "abort"+@@ -1,1307 +1,1308 @@⋯ 318 unchanged lines ⋯ [[package]] name = "kicad-bridge"-version = "1.0.105"+version = "1.0.106" dependencies = [ "adom-eda-checks", "kicad-core",⋯ 5 unchanged lines ⋯ [[package]] name = "kicad-core"-version = "1.0.105"+version = "1.0.106" dependencies = [ "kicad-ipc-rs", "kicad-platform",⋯ 20 unchanged lines ⋯ [[package]] name = "kicad-platform"-version = "1.0.105"+version = "1.0.106" dependencies = [ "serde", "serde_json",⋯ 940 unchanged lines ⋯ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"+@@ -1,52 +1,85 @@⋯ 37 unchanged lines ⋯ 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)))";⋯ 1 unchanged line ⋯ 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());} }++@@ -1,1935 +1,1955 @@⋯ 1187 unchanged lines ⋯ // -- 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 {⋯ 44 unchanged lines ⋯ } } 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")); } } }⋯ 648 unchanged lines ⋯ 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\")\ (pad \"1\" smd rect (at -1 0) (size 1 1) (layers \"F.Cu\") (net \"A\")) (pad \"2\" smd rect (at 1 0) (size 1 1) (layers \"F.Cu\") (net \"B\")))\⋯ 28 unchanged lines ⋯ assert_eq!(v["polygons"].as_array().unwrap().len(), 2, "a two-layer zone is one polygon per layer"); } }+@@ -1,800 +1,803 @@⋯ 177 unchanged lines ⋯ 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."], },⋯ 584 unchanged lines ⋯ 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."),⋯ 22 unchanged lines ⋯ assert!(super::camera_preset("spin").is_none()); } }+@@ -1,1631 +1,1632 @@⋯ 20 unchanged lines ⋯ 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."], },⋯ 1 unchanged line ⋯ 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."], },⋯ 10 unchanged lines ⋯ 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."], },⋯ 1570 unchanged lines ⋯ assert!(v["jobs"].is_array()); } }+@@ -1,292 +1,293 @@⋯ 73 unchanged lines ⋯ 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")⋯ 209 unchanged lines ⋯ } } }+
Comments
Log in to comment.
No comments yet.