← 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).

John Lauer ·83b3b1db84 ·1mo ago ·parent 6f8e15e
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());     }-}\ No newline at end of file+}
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());+ }+}+