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.21: NATIVE (Rust) build. 1.0.21: merges Astra's PR #7: 3D viewer bound to its source editor and refuses ambiguity (wiki #102), no substitute windows (#99), canvasRendered needs lit pixels (#100), kicad_3d_view pan (#105), read-only kicad_text_bounds via GetTextExtents (#104).
6 files changed
+95−16
rust/Cargo.lock+3−3@@ -244,7 +244,7 @@ dependencies = [ [[package]] name = "kicad-bridge"-version = "1.0.20"+version = "1.0.21" dependencies = [ "kicad-core", "kicad-platform",@@ -255,7 +255,7 @@ dependencies = [ [[package]] name = "kicad-core"-version = "1.0.20"+version = "1.0.21" dependencies = [ "kicad-ipc-rs", "kicad-platform",@@ -281,7 +281,7 @@ dependencies = [ [[package]] name = "kicad-platform"-version = "1.0.20"+version = "1.0.21" dependencies = [ "serde", "serde_json",
rust/Cargo.toml+1−1@@ -3,7 +3,7 @@ resolver = "2" members = ["crates/*"] [workspace.package]-version = "1.0.20"+version = "1.0.21" edition = "2021" license = "MIT" publish = false
rust/crates/kicad-bridge/src/verbs_routing.rs+14−1@@ -17,6 +17,16 @@ use kicad_core::pcb::{self, NetRef}; use kicad_core::{dsn, freerouting, progress}; pub static VERBS: &[Verb] = &[+ Verb {+ name: "kicad_text_bounds",+ summary: "Measure candidate silkscreen text using native KiCad text metrics without editing.",+ mechanism: Mechanism::Ipc, risk: "read", timeout_sec: 130,+ input: "{filePath, expectedRevision?, texts:[{id?,text,x?,y?,height?,width?,stroke?,rotation?,align?:left|center|right,verticalAlign?:top|center|bottom,mirrored?,font?,bold?,italic?}]}; dimensions in mm",+ example: "kicad_text_bounds {\"filePath\":\"C:/p/b.kicad_pcb\",\"texts\":[{\"text\":\"DSHOT\",\"height\":0.3,\"align\":\"left\"}]}",+ hint: "Use native bounds before solving labels. Mirroring and justification affect the box. No text is created by this read-only command.",+ related: &["kicad_routing_state"],+ pitfalls: &["Bodies, holes and exposed pads remain separate obstacles."],+ }, Verb { name: "kicad_routing_state", summary: "Inspect the live editor: pads, copper, revision and KiCad-measured net connectivity.",@@ -172,6 +182,7 @@ pub static VERBS: &[Verb] = &[ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> { Some(match command { "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),@@ -197,6 +208,7 @@ pub fn dispatch(state: &mut State, command: &str, args: &Value) -> Option<Value> #[derive(Clone, Copy)] enum Live { State,+ TextBounds, RouteNet, RemoveRoute, Validate,@@ -217,6 +229,7 @@ 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::RouteNet => ipc::route_net(&ctx, args), Live::RemoveRoute => ipc::remove_route(&ctx, args), Live::Validate => ipc::routing_validate(&ctx, args),@@ -1501,4 +1514,4 @@ mod tests { assert_eq!(v["job"], json!("autoroute-0-missing")); assert!(v["jobs"].is_array()); }-}+}
rust/crates/kicad-bridge/src/verbs_show.rs+4@@ -253,11 +253,13 @@ fn surface_window(kind: WindowKind, prefer_hwnd: Option<u64>, needle: &str) -> O if let Some(w) = wins.iter().find(|w| w.hwnd == h) { return Some(w.clone()); }+ return None; // Never substitute another window for an explicit HWND. } if !needle.is_empty() { if let Some(w) = wins.iter().find(|w| w.title.to_lowercase().contains(needle)) { return Some(w.clone()); }+ return None; // A missing requested document is not any open editor. } wins.into_iter().next() }@@ -647,6 +649,7 @@ fn show_3d_board(state: &mut State, args: &Value) -> Value { // needs a live PCB editor) so we do not race "not open". step(CMD_3D_BOARD, "waiting for the PCB Editor window", 30); pcb_hwnd = wait_for_surface(WindowKind::PcbEditor, opened.get("hwnd").and_then(Value::as_u64), &stem_of(f), 40, 750).map(|w| w.hwnd);+ if pcb_hwnd.is_none() { return json!({"success":false,"errorCode":"requested_board_not_open","error":"The requested PCB editor did not appear.","_hint":"Inspect kicad_open_editors and dialogs; resolve any file lock before retrying kicad_show_3d_board with the same filePath."}); } } step(CMD_3D_BOARD, "opening the 3D Viewer from the PCB Editor", 40); let mut a = args.clone();@@ -970,3 +973,4 @@ mod tests { assert_eq!(surface_kind("show_project"), None); } }+
rust/crates/kicad-bridge/src/verbs_windows.rs+44−11@@ -114,7 +114,7 @@ pub static VERBS: &[Verb] = &[ mechanism: Mechanism::Window, risk: "input", timeout_sec: 120, input: "{\"action\": \"zoom_in|zoom_out|fit|rotate_x_cw|rotate_x_ccw|rotate_y_cw|rotate_y_ccw|rotate_z_cw|rotate_z_ccw|view_top|view_bottom|view_front|view_back|view_left|view_right|flip|tour|actions\", \"count\"?: 3, \"pauseMs\"?: 700, \"hwnd\"?: 123, \"steps\"?: [[\"rotate_z_cw\", 8], [\"zoom_in\", 3]]}", example: "kicad_3d_view {\"action\":\"rotate_z_cw\",\"count\":8,\"pauseMs\":700}",- hint: "Every action is a View-menu command fired on the 3D Viewer window by WM_COMMAND: no focus, no cursor, works with the viewer in the background (a window recording of it still sees every frame). count repeats the action with pauseMs between, so a slow orbit is one call. action:\"tour\" runs the default walkthrough (fit, top, tilt, orbit, zoom in, swing, zoom out, front, bottom, flip, top, fit) or your own steps list. Open the viewer first with kicad_open_3d_viewer. The viewer has no menu command for panning; zoom about the centre and rotate instead.",+ hint: "Every action is a View-menu command fired on the 3D Viewer window by WM_COMMAND: no focus, no cursor, works with the viewer in the background (a window recording of it still sees every frame). count repeats the action with pauseMs between, so a slow orbit is one call. action:\"tour\" runs the default walkthrough (fit, top, tilt, orbit, zoom in, swing, zoom out, front, bottom, flip, top, fit) or your own steps list. Open the viewer first with kicad_open_3d_viewer. pan_left, pan_right, pan_up and pan_down discover Move Board menu commands at runtime. Reference framing is not yet available.", related: &["kicad_open_3d_viewer", "kicad_send_key", "kicad_screenshot_all", "kicad_model_check"], pitfalls: &["the viewer must be open (kicad_open_3d_viewer)", "a huge board takes seconds per redraw; keep pauseMs at 500 or more"], },@@ -1955,12 +1955,26 @@ pub(crate) fn open_footprint_editor(state: &mut State, args: &Value) -> Value { // ── kicad_open_3d_viewer ────────────────────────────────────────────────────── -fn poll_3d_window(rounds: usize) -> Option<Win> {- for _ in 0..rounds.max(1) {- sleep_ms(750);- invalidate();- if let Some(w) = windows_of_kind(WindowKind::Viewer3d).into_iter().next() {- return Some(w);+static VIEWER_SOURCE_BINDINGS: Mutex<BTreeMap<u64,(u32,u64)>> = Mutex::new(BTreeMap::new());+fn viewer_for_frame(frame:u64, source_pid:u32, windows:Vec<Win>, before:&[u64], editor_count:usize, known:Option<u64>)->Option<Win>{+ let eligible:Vec<_>=windows.into_iter().filter(|w|source_pid!=0 && w.pid==source_pid && (w.owner==0 || w.owner==frame)).collect();+ let exact:Vec<_>=eligible.iter().filter(|w|w.owner==frame).cloned().collect();+ if exact.len()==1{return exact.into_iter().next()}+ if exact.len()>1{return None}+ if let Some(id)=known {if let Some(w)=eligible.iter().find(|w|w.hwnd==id){return Some(w.clone())}}+ let created:Vec<_>=eligible.iter().filter(|w|!before.contains(&w.hwnd)).cloned().collect();+ if created.len()==1{return created.into_iter().next()}+ if editor_count==1 && eligible.len()==1{return eligible.into_iter().next()}+ None+}+fn poll_3d_window(frame:u64,source_pid:u32,before:&[u64],rounds:usize)->Option<Win>{+ for _ in 0..rounds.max(1){+ sleep_ms(750);invalidate();+ let editor_count=kicad_windows().iter().filter(|w|w.pid==source_pid && matches!(w.kind(),WindowKind::PcbEditor|WindowKind::FootprintEditor)).count();+ let known=VIEWER_SOURCE_BINDINGS.lock().ok().and_then(|m|m.get(&frame).filter(|(pid,_)|*pid==source_pid).map(|(_,h)|*h));+ if let Some(w)=viewer_for_frame(frame,source_pid,windows_of_kind(WindowKind::Viewer3d),before,editor_count,known){+ if let Ok(mut map)=VIEWER_SOURCE_BINDINGS.lock(){map.insert(frame,(source_pid,w.hwnd));}+ return Some(w) } } None@@ -1969,12 +1983,17 @@ fn poll_3d_window(rounds: usize) -> Option<Win> { /// The rungs for one source frame: native menu walk, then UIA Invoke. Ok(Some) when a /// 3D Viewer window appeared. fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<Option<Value>, Value> {+ let source_pid = match kicad_windows().into_iter().find(|w| w.hwnd == frame) {+ Some(w) => w.pid,+ None => return Err(fail("source_editor_missing", "The requested source editor is no longer open.", "Call kicad_open_editors and pass the current pcbHwnd or fpHwnd.")),+ };+ let before:Vec<u64>=windows_of_kind(WindowKind::Viewer3d).iter().map(|w|w.hwnd).collect(); let mut unsupported_menu: Option<Value> = None; match menu_invoke_by_text(frame, "3d viewer") { Ok(Some((id, _))) => { pathways.push(format!("win32menu(id={id})"));- if let Some(w) = poll_3d_window(12) {- return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "title": w.title, "output": format!("3D Viewer opened from {label} (Win32 menu): {}", w.title), "pathwaysTried": pathways})));+ if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {+ return Ok(Some(json!({"success": true, "pathway": "win32menu", "mechanism": "menu", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} (Win32 menu): {}", w.title), "pathwaysTried": pathways}))); } } Ok(None) => pathways.push("win32menu-not-found".into()),@@ -1984,8 +2003,8 @@ fn open_3d_from(frame: u64, label: &str, pathways: &mut Vec<String>) -> Result<O match native().uia_invoke(frame, "3D Viewer") { Ok(_) => { pathways.push("uia".into());- if let Some(w) = poll_3d_window(12) {- return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "title": w.title, "output": format!("3D Viewer opened from {label} via UIA: {}", w.title), "pathwaysTried": pathways})));+ if let Some(w) = poll_3d_window(frame, source_pid, &before, 12) {+ return Ok(Some(json!({"success": true, "pathway": "uia", "mechanism": "uia", "hwnd": w.hwnd, "sourceHwnd": frame, "sourcePid": source_pid, "viewerPid": w.pid, "title": w.title, "output": format!("3D Viewer opened from {label} via UIA: {}", w.title), "pathwaysTried": pathways}))); } } Err(e) if is_not_impl(&e) => {@@ -2022,6 +2041,8 @@ pub(crate) fn menu_items(args: &Value) -> Value { pub(crate) fn view_3d(args: &Value) -> Value { const ACTIONS: &[(&str, &str)] = &[+ ("pan_left", "Move Board Left"), ("pan_right", "Move Board Right"),+ ("pan_up", "Move Board Up"), ("pan_down", "Move Board Down"), ("zoom_in", "Zoom In"), ("zoom_out", "Zoom Out"), ("fit", "Zoom to Fit"), ("rotate_x_cw", "Rotate X Clockwise"), ("rotate_x_ccw", "Rotate X Counterclockwise"), ("rotate_y_cw", "Rotate Y Clockwise"), ("rotate_y_ccw", "Rotate Y Counterclockwise"),@@ -2939,6 +2960,17 @@ fn enable_software_opengl(state: &mut State) -> Value { #[cfg(test)] mod tests { use super::*;+ #[test]+ fn viewer_selection_is_bound_to_source_and_refuses_ambiguity(){+ let win=|hwnd,owner,pid|Win{hwnd,owner,pid,title:"3D Viewer".into(),class_name:"wxWindowNR".into(),rect:(0,0,100,100),z:None,exe:"pcbnew.exe".into(),minimized:false,maximized:false,hung:false};+ assert_eq!(viewer_for_frame(2,200,vec![win(10,1,200),win(20,2,200)],&[10,20],2,None).unwrap().hwnd,20);+ assert!(viewer_for_frame(2,200,vec![win(10,1,200)],&[10],2,None).is_none());+ assert!(viewer_for_frame(2,200,vec![win(10,0,200)],&[10],2,None).is_none());+ assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10],2,None).unwrap().hwnd,20);+ assert!(viewer_for_frame(2,200,vec![win(20,0,200),win(21,0,200)],&[],2,None).is_none());+ assert_eq!(viewer_for_frame(2,200,vec![win(10,0,200),win(20,0,200)],&[10,20],2,Some(20)).unwrap().hwnd,20);+ }+ #[test] fn footprint_splice_matches_python() {@@ -3138,3 +3170,4 @@ fn rescan_libraries(state: &mut State, args: &Value) -> Value { json!({"success": false, "restarted": true, "ready": false, "errorCode": "restart_no_window", "_hint": "KiCad was restarted but no window appeared within 60 s; kicad_window_info shows what is up."}) }+
rust/crates/kicad-core/src/ipc.rs+29@@ -1581,3 +1581,32 @@ mod tests { assert_eq!(build_items(&bad, &BoardNet { code: 1, name: "NET_1".into() }).unwrap_err().code, "unknown_layer"); } }++/// Native rendered text bounds without changing the board.+fn text_spec_from_json(v:&Value)->RResult<kicad_ipc_rs::TextSpec> {+ use kicad_ipc_rs::{TextSpec,TextAttributesSpec,TextHorizontalAlignment as H,TextVerticalAlignment as V};+ let text=v["text"].as_str().ok_or_else(||RoutingError::new("invalid_text","text is required"))?;+ let number=|k:&str,d:f64|->RResult<f64>{let n=v.get(k).map(|x|x.as_f64().unwrap_or(f64::NAN)).unwrap_or(d);if n.is_finite(){Ok(n)}else{Err(RoutingError::new("invalid_text",format!("{k} must be finite")))}};+ let height=number("height",1.0)?;let width=number("width",height)?;let stroke=number("stroke",height*0.15)?;+ if height<=0.0 || width<=0.0 || stroke<=0.0 || height>1000.0 || width>1000.0{return Err(RoutingError::new("invalid_text","positive dimensions in mm required; maximum text size 1000 mm"))}+ let align=match v["align"].as_str().unwrap_or("center"){"left"=>H::Left,"center"=>H::Center,"right"=>H::Right,_=>return Err(RoutingError::new("invalid_text","align must be left, center or right"))};+ let vertical=match v["verticalAlign"].as_str().unwrap_or("center"){"top"=>V::Top,"center"=>V::Center,"bottom"=>V::Bottom,_=>return Err(RoutingError::new("invalid_text","verticalAlign must be top, center or bottom"))};+ Ok(TextSpec{text:text.into(),position_nm:Some(Vector2Nm{x_nm:(number("x",0.0)?*1e6).round() as i64,y_nm:(number("y",0.0)?*1e6).round() as i64}),attributes:Some(TextAttributesSpec{font_name:v["font"].as_str().map(str::to_string),horizontal_alignment:align,vertical_alignment:vertical,angle_degrees:Some(number("rotation",0.0)?),stroke_width_nm:Some((stroke*1e6).round() as i64),size_nm:Some(Vector2Nm{x_nm:(width*1e6).round() as i64,y_nm:(height*1e6).round() as i64}),mirrored:v["mirrored"].as_bool().unwrap_or(false),bold:v["bold"].as_bool().unwrap_or(false),italic:v["italic"].as_bool().unwrap_or(false),multiline:text.contains('\n'),..Default::default()}),hyperlink:None})+}+pub fn text_bounds(ctx:&Ctx,args:&Value)->RResult<Value>{+ let rows=args["texts"].as_array().ok_or_else(||RoutingError::new("invalid_text","texts must be a nonempty array"))?;+ if rows.is_empty() || rows.len()>2000{return Err(RoutingError::new("invalid_text","texts must contain 1..2000 entries"))}+ let specs=rows.iter().map(text_spec_from_json).collect::<RResult<Vec<_>>>()?;+ let s=connect(ctx,args)?;let revision=s.revision()?;if args.get("expectedRevision").is_some(){check_revision(args,&revision)?;}+ let mut bounds=Vec::new();+ for (row,spec) in rows.iter().zip(specs){let b=s.client.get_text_extents(spec).map_err(map_err)?;bounds.push(json!({"id":row["id"],"text":row["text"],"box":[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]}));}+ Ok(json!({"success":true,"mutated":false,"revision":revision,"source":"native-GetTextExtents","units":"mm","texts":bounds,"_hint":"Native axis-aligned rendered bounds at the requested rotation and justification. Keep holes, exposed pads and component bodies as separate obstacles. This does not place text or certify readability."}))+}+#[cfg(test)] mod text_metric_tests {+ use super::*;+ #[test] fn alignment_and_bottom_mirroring(){+ let s=text_spec_from_json(&json!({"text":"MC10\nDSHOT","x":12.5,"height":0.3,"stroke":0.05,"align":"right","mirrored":true,"rotation":90})).unwrap();assert_eq!(s.position_nm.unwrap().x_nm,12_500_000);let a=s.attributes.unwrap();assert_eq!(a.horizontal_alignment,kicad_ipc_rs::TextHorizontalAlignment::Right);assert!(a.mirrored&&a.multiline);assert_eq!(a.size_nm.unwrap().y_nm,300_000);+ assert!(text_spec_from_json(&json!({"text":"x","height":0})).is_err());assert!(text_spec_from_json(&json!({"text":"x","align":"random"})).is_err());+ }+}+