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.
Expose native KiCad text bounds for silkscreen solvers #6
Expose kicad_text_bounds through the official IPC GetTextExtents operation. AI Flow currently has to obtain these metrics through its own native helper; this makes the reusable EDA primitive available to all Bridge callers, addressing the measurement portion of issue #104.
The read-only call accepts a bounded text batch with millimetre position, width/height, stroke, rotation, font, bold/italic, horizontal/vertical alignment and mirroring. It connects to the exact requested board and optionally checks expectedRevision. Results are native axis-aligned rendered bounds, not character-count estimates. Invalid dimensions and alignment fail before IPC. Bodies, pad exposure and holes remain separate obstacles; it does not claim to solve placement or visual readability.
Validation: cargo test --workspace --lib --bins passed, including native text request conversion tests for right alignment, bottom mirroring, multiline text and 0.3 mm height. A live Windows acceptance call is still needed after maintainer deployment. No shared runtime was replaced. Stable-ID silkscreen edit batches and obstacle geometry remain follow-on work for #104, so this does not close the whole issue.
Diff Skip to comments (1)
@@ -1,1583 +1,1612 @@⋯ 1580 unchanged lines ⋯ 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());+ }+}+@@ -1,1492 +1,1505 @@⋯ 17 unchanged lines ⋯ 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.", 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_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),⋯ 19 unchanged lines ⋯ #[derive(Clone, Copy)] enum Live { State,+ TextBounds, RouteNet, RemoveRoute, Validate,⋯ 14 unchanged lines ⋯ }; 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),⋯ 1271 unchanged lines ⋯
Comments
Log in to comment.
Superseded by https://wiki.adom.inc/adom/kicad-bridge/prs/7, rebased onto Bridge 1.0.17 and including the same-process, multiple-editor viewer ambiguity regression. Closing this older file set to avoid merging stale source. Native Windows acceptance remains pending.