diff --git a/docs/TEST_CLI.md b/docs/TEST_CLI.md index 6ba9652..d88f5ae 100644 --- a/docs/TEST_CLI.md +++ b/docs/TEST_CLI.md @@ -101,6 +101,30 @@ Show pad-level netlist entries (footprint/pad/net): cargo run --bin kicad-ipc-cli -- netlist-pads ``` +Show parsed details for specific item IDs: + +```bash +cargo run --bin kicad-ipc-cli -- items-by-id --id --id +``` + +Show item bounding boxes: + +```bash +cargo run --bin kicad-ipc-cli -- item-bbox --id +``` + +Include child text in the bounding box (for items such as footprints): + +```bash +cargo run --bin kicad-ipc-cli -- item-bbox --id --include-text +``` + +Run hit-test on a specific item: + +```bash +cargo run --bin kicad-ipc-cli -- hit-test --id --x-nm --y-nm --tolerance-nm 0 +``` + Get current project path (derived from open PCB docs): ```bash diff --git a/src/client.rs b/src/client.rs index 6534551..2328c71 100644 --- a/src/client.rs +++ b/src/client.rs @@ -9,8 +9,8 @@ use crate::model::board::{ BoardEnabledLayers, BoardLayerInfo, BoardNet, BoardOriginKind, PadNetEntry, Vector2Nm, }; use crate::model::common::{ - DocumentSpecifier, DocumentType, ProjectInfo, SelectionItemDetail, SelectionSummary, - SelectionTypeCount, VersionInfo, + DocumentSpecifier, DocumentType, ItemBoundingBox, ItemHitTestResult, ProjectInfo, + SelectionItemDetail, SelectionSummary, SelectionTypeCount, VersionInfo, }; use crate::proto::kiapi::board::commands as board_commands; use crate::proto::kiapi::board::types as board_types; @@ -31,6 +31,9 @@ const CMD_GET_VISIBLE_LAYERS: &str = "kiapi.board.commands.GetVisibleLayers"; const CMD_GET_BOARD_ORIGIN: &str = "kiapi.board.commands.GetBoardOrigin"; const CMD_GET_SELECTION: &str = "kiapi.common.commands.GetSelection"; const CMD_GET_ITEMS: &str = "kiapi.common.commands.GetItems"; +const CMD_GET_ITEMS_BY_ID: &str = "kiapi.common.commands.GetItemsById"; +const CMD_GET_BOUNDING_BOX: &str = "kiapi.common.commands.GetBoundingBox"; +const CMD_HIT_TEST: &str = "kiapi.common.commands.HitTest"; const RES_GET_VERSION: &str = "kiapi.common.commands.GetVersionResponse"; const RES_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocumentsResponse"; @@ -41,6 +44,8 @@ const RES_BOARD_LAYERS: &str = "kiapi.board.commands.BoardLayers"; const RES_VECTOR2: &str = "kiapi.common.types.Vector2"; const RES_SELECTION_RESPONSE: &str = "kiapi.common.commands.SelectionResponse"; const RES_GET_ITEMS_RESPONSE: &str = "kiapi.common.commands.GetItemsResponse"; +const RES_GET_BOUNDING_BOX_RESPONSE: &str = "kiapi.common.commands.GetBoundingBoxResponse"; +const RES_HIT_TEST_RESPONSE: &str = "kiapi.common.commands.HitTestResponse"; #[derive(Clone, Debug)] pub struct KiCadClient { @@ -318,13 +323,8 @@ impl KiCadClient { } pub async fn get_selection_raw(&self) -> Result, KiCadError> { - let document = self.current_board_document_proto().await?; let command = common_commands::GetSelection { - header: Some(common_types::ItemHeader { - document: Some(document), - container: None, - field_mask: None, - }), + header: Some(self.current_board_item_header().await?), types: Vec::new(), }; @@ -340,19 +340,7 @@ impl KiCadClient { pub async fn get_selection_details(&self) -> Result, KiCadError> { let items = self.get_selection_raw().await?; - let mut details = Vec::with_capacity(items.len()); - for item in items { - let raw_len = item.value.len(); - let type_url = item.type_url.clone(); - let detail = selection_item_detail(&item)?; - details.push(SelectionItemDetail { - type_url, - detail, - raw_len, - }); - } - - Ok(details) + summarize_item_details(items) } pub async fn get_pad_netlist(&self) -> Result, KiCadError> { @@ -362,6 +350,101 @@ impl KiCadClient { pad_netlist_from_footprint_items(footprint_items) } + pub async fn get_items_by_id_raw( + &self, + item_ids: Vec, + ) -> Result, KiCadError> { + if item_ids.is_empty() { + return Ok(Vec::new()); + } + + let command = common_commands::GetItemsById { + header: Some(self.current_board_item_header().await?), + items: item_ids + .into_iter() + .map(|id| common_types::Kiid { value: id }) + .collect(), + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_ITEMS_BY_ID)) + .await?; + + let payload: common_commands::GetItemsResponse = + envelope::unpack_any(&response, RES_GET_ITEMS_RESPONSE)?; + + ensure_item_request_ok(payload.status)?; + Ok(payload.items) + } + + pub async fn get_items_by_id_details( + &self, + item_ids: Vec, + ) -> Result, KiCadError> { + let items = self.get_items_by_id_raw(item_ids).await?; + summarize_item_details(items) + } + + pub async fn get_item_bounding_boxes( + &self, + item_ids: Vec, + include_child_text: bool, + ) -> Result, KiCadError> { + if item_ids.is_empty() { + return Ok(Vec::new()); + } + + let mode = if include_child_text { + common_commands::BoundingBoxMode::BbmItemAndChildText + } else { + common_commands::BoundingBoxMode::BbmItemOnly + }; + + let command = common_commands::GetBoundingBox { + header: Some(self.current_board_item_header().await?), + items: item_ids + .into_iter() + .map(|id| common_types::Kiid { value: id }) + .collect(), + mode: mode as i32, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_GET_BOUNDING_BOX)) + .await?; + + let payload: common_commands::GetBoundingBoxResponse = + envelope::unpack_any(&response, RES_GET_BOUNDING_BOX_RESPONSE)?; + + map_item_bounding_boxes(payload.items, payload.boxes) + } + + pub async fn hit_test_item( + &self, + item_id: String, + position: Vector2Nm, + tolerance_nm: i32, + ) -> Result { + let command = common_commands::HitTest { + header: Some(self.current_board_item_header().await?), + id: Some(common_types::Kiid { value: item_id }), + position: Some(common_types::Vector2 { + x_nm: position.x_nm, + y_nm: position.y_nm, + }), + tolerance: tolerance_nm, + }; + + let response = self + .send_command(envelope::pack_any(&command, CMD_HIT_TEST)) + .await?; + + let payload: common_commands::HitTestResponse = + envelope::unpack_any(&response, RES_HIT_TEST_RESPONSE)?; + + Ok(map_hit_test_result(payload.result)) + } + async fn send_command( &self, command: prost_types::Any, @@ -406,14 +489,17 @@ impl KiCadClient { Ok(model_document_to_proto(selected)) } + async fn current_board_item_header(&self) -> Result { + Ok(common_types::ItemHeader { + document: Some(self.current_board_document_proto().await?), + container: None, + field_mask: None, + }) + } + async fn get_items_raw(&self, types: Vec) -> Result, KiCadError> { - let document = self.current_board_document_proto().await?; let command = common_commands::GetItems { - header: Some(common_types::ItemHeader { - document: Some(document), - container: None, - field_mask: None, - }), + header: Some(self.current_board_item_header().await?), types, }; @@ -424,15 +510,7 @@ impl KiCadClient { let payload: common_commands::GetItemsResponse = envelope::unpack_any(&response, RES_GET_ITEMS_RESPONSE)?; - let request_status = common_types::ItemRequestStatus::try_from(payload.status) - .unwrap_or(common_types::ItemRequestStatus::IrsUnknown); - - if request_status != common_types::ItemRequestStatus::IrsOk { - return Err(KiCadError::ItemStatus { - code: request_status.as_str_name().to_string(), - }); - } - + ensure_item_request_ok(payload.status)?; Ok(payload.items) } } @@ -522,6 +600,73 @@ fn summarize_selection(items: Vec) -> SelectionSummary { } } +fn summarize_item_details( + items: Vec, +) -> Result, KiCadError> { + let mut details = Vec::with_capacity(items.len()); + for item in items { + let raw_len = item.value.len(); + let type_url = item.type_url.clone(); + let detail = selection_item_detail(&item)?; + details.push(SelectionItemDetail { + type_url, + detail, + raw_len, + }); + } + + Ok(details) +} + +fn ensure_item_request_ok(status: i32) -> Result<(), KiCadError> { + let request_status = common_types::ItemRequestStatus::try_from(status) + .unwrap_or(common_types::ItemRequestStatus::IrsUnknown); + + if request_status != common_types::ItemRequestStatus::IrsOk { + return Err(KiCadError::ItemStatus { + code: request_status.as_str_name().to_string(), + }); + } + + Ok(()) +} + +fn map_item_bounding_boxes( + item_ids: Vec, + boxes: Vec, +) -> Result, KiCadError> { + let mut mapped = Vec::with_capacity(item_ids.len().min(boxes.len())); + for (item_id, bbox) in item_ids.into_iter().zip(boxes.into_iter()) { + let position = bbox.position.ok_or_else(|| KiCadError::InvalidResponse { + reason: format!("missing bounding-box position for item `{}`", item_id.value), + })?; + let size = bbox.size.ok_or_else(|| KiCadError::InvalidResponse { + reason: format!("missing bounding-box size for item `{}`", item_id.value), + })?; + + mapped.push(ItemBoundingBox { + item_id: item_id.value, + x_nm: position.x_nm, + y_nm: position.y_nm, + width_nm: size.x_nm, + height_nm: size.y_nm, + }); + } + + Ok(mapped) +} + +fn map_hit_test_result(value: i32) -> ItemHitTestResult { + let result = common_commands::HitTestResult::try_from(value) + .unwrap_or(common_commands::HitTestResult::HtrUnknown); + + match result { + common_commands::HitTestResult::HtrHit => ItemHitTestResult::Hit, + common_commands::HitTestResult::HtrNoHit => ItemHitTestResult::NoHit, + common_commands::HitTestResult::HtrUnknown => ItemHitTestResult::Unknown, + } +} + fn decode_any( payload: &prost_types::Any, expected_type_name: &str, @@ -789,9 +934,10 @@ fn default_client_name() -> String { #[cfg(test)] mod tests { use super::{ - layer_to_model, model_document_to_proto, normalize_socket_uri, - pad_netlist_from_footprint_items, select_single_board_document, select_single_project_path, - selection_item_detail, summarize_selection, + ensure_item_request_ok, layer_to_model, map_hit_test_result, map_item_bounding_boxes, + model_document_to_proto, normalize_socket_uri, pad_netlist_from_footprint_items, + select_single_board_document, select_single_project_path, selection_item_detail, + summarize_item_details, summarize_selection, }; use crate::error::KiCadError; use crate::model::common::{DocumentSpecifier, DocumentType, ProjectInfo}; @@ -1072,4 +1218,67 @@ mod tests { assert_eq!(entry.pad_number, "1"); assert_eq!(entry.net_code, Some(5)); } + + #[test] + fn ensure_item_request_ok_accepts_ok_and_rejects_non_ok() { + assert!(ensure_item_request_ok( + crate::proto::kiapi::common::types::ItemRequestStatus::IrsOk as i32 + ) + .is_ok()); + + assert!(ensure_item_request_ok( + crate::proto::kiapi::common::types::ItemRequestStatus::IrsDocumentNotFound as i32 + ) + .is_err()); + } + + #[test] + fn summarize_item_details_reports_unknown_payload_as_unparsed() { + let items = vec![prost_types::Any { + type_url: "type.googleapis.com/kiapi.board.types.UnknownThing".to_string(), + value: vec![1, 2, 3, 4], + }]; + + let details = + summarize_item_details(items).expect("unknown types should still produce detail rows"); + assert_eq!(details.len(), 1); + assert!(details[0].detail.contains("unparsed payload")); + assert_eq!(details[0].raw_len, 4); + } + + #[test] + fn map_item_bounding_boxes_maps_ids_and_dimensions() { + let ids = vec![crate::proto::kiapi::common::types::Kiid { + value: "id-1".to_string(), + }]; + let boxes = vec![crate::proto::kiapi::common::types::Box2 { + position: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 10, y_nm: 20 }), + size: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 30, y_nm: 40 }), + }]; + + let mapped = map_item_bounding_boxes(ids, boxes) + .expect("box mapping should succeed when position and size are present"); + assert_eq!(mapped.len(), 1); + assert_eq!(mapped[0].item_id, "id-1"); + assert_eq!(mapped[0].x_nm, 10); + assert_eq!(mapped[0].y_nm, 20); + assert_eq!(mapped[0].width_nm, 30); + assert_eq!(mapped[0].height_nm, 40); + } + + #[test] + fn map_hit_test_result_covers_known_variants() { + assert_eq!( + map_hit_test_result( + crate::proto::kiapi::common::commands::HitTestResult::HtrHit as i32 + ), + crate::model::common::ItemHitTestResult::Hit + ); + assert_eq!( + map_hit_test_result( + crate::proto::kiapi::common::commands::HitTestResult::HtrNoHit as i32 + ), + crate::model::common::ItemHitTestResult::NoHit + ); + } } diff --git a/src/error.rs b/src/error.rs index 39e8f0a..a096e1d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -31,6 +31,9 @@ pub enum KiCadError { #[error("item request status error `{code}`")] ItemStatus { code: String }, + #[error("invalid API response: {reason}")] + InvalidResponse { reason: String }, + #[error("API response missing payload for `{expected_type_url}`")] MissingPayload { expected_type_url: String }, diff --git a/src/lib.rs b/src/lib.rs index 6970844..e42bff9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,6 @@ pub use crate::model::board::{ BoardEnabledLayers, BoardLayerInfo, BoardNet, BoardOriginKind, PadNetEntry, Vector2Nm, }; pub use crate::model::common::{ - DocumentSpecifier, DocumentType, SelectionItemDetail, SelectionSummary, SelectionTypeCount, - VersionInfo, + DocumentSpecifier, DocumentType, ItemBoundingBox, ItemHitTestResult, SelectionItemDetail, + SelectionSummary, SelectionTypeCount, VersionInfo, }; diff --git a/src/model/common.rs b/src/model/common.rs index 8a11631..f99026c 100644 --- a/src/model/common.rs +++ b/src/model/common.rs @@ -111,3 +111,31 @@ pub struct SelectionItemDetail { pub detail: String, pub raw_len: usize, } + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ItemBoundingBox { + pub item_id: String, + pub x_nm: i64, + pub y_nm: i64, + pub width_nm: i64, + pub height_nm: i64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ItemHitTestResult { + Unknown, + NoHit, + Hit, +} + +impl std::fmt::Display for ItemHitTestResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let value = match self { + Self::Unknown => "unknown", + Self::NoHit => "no-hit", + Self::Hit => "hit", + }; + + write!(f, "{value}") + } +} diff --git a/test-scripts/kicad-ipc-cli.rs b/test-scripts/kicad-ipc-cli.rs index 3e06c8f..3c8b0b7 100644 --- a/test-scripts/kicad-ipc-cli.rs +++ b/test-scripts/kicad-ipc-cli.rs @@ -2,7 +2,7 @@ use std::process::ExitCode; use std::str::FromStr; use std::time::Duration; -use kicad_ipc::{BoardOriginKind, ClientBuilder, DocumentType, KiCadError}; +use kicad_ipc::{BoardOriginKind, ClientBuilder, DocumentType, KiCadError, Vector2Nm}; #[derive(Debug)] struct CliConfig { @@ -15,18 +15,35 @@ struct CliConfig { enum Command { Ping, Version, - OpenDocs { document_type: DocumentType }, + OpenDocs { + document_type: DocumentType, + }, ProjectPath, BoardOpen, Nets, EnabledLayers, ActiveLayer, VisibleLayers, - BoardOrigin { kind: BoardOriginKind }, + BoardOrigin { + kind: BoardOriginKind, + }, SelectionSummary, SelectionDetails, SelectionRaw, NetlistPads, + ItemsById { + item_ids: Vec, + }, + ItemBBox { + item_ids: Vec, + include_child_text: bool, + }, + HitTest { + item_id: String, + x_nm: i64, + y_nm: i64, + tolerance_nm: i32, + }, Smoke, Help, } @@ -209,6 +226,42 @@ async fn run() -> Result<(), KiCadError> { ); } } + Command::ItemsById { item_ids } => { + let details = client.get_items_by_id_details(item_ids).await?; + println!("items_total={}", details.len()); + for (index, item) in details.iter().enumerate() { + println!( + "[{index}] type_url={} raw_len={} detail={}", + item.type_url, item.raw_len, item.detail + ); + } + } + Command::ItemBBox { + item_ids, + include_child_text, + } => { + let boxes = client + .get_item_bounding_boxes(item_ids, include_child_text) + .await?; + println!("bbox_total={}", boxes.len()); + for entry in boxes { + println!( + "item_id={} x_nm={} y_nm={} width_nm={} height_nm={}", + entry.item_id, entry.x_nm, entry.y_nm, entry.width_nm, entry.height_nm + ); + } + } + Command::HitTest { + item_id, + x_nm, + y_nm, + tolerance_nm, + } => { + let result = client + .hit_test_item(item_id, Vector2Nm { x_nm, y_nm }, tolerance_nm) + .await?; + println!("hit_test={result}"); + } Command::Smoke => { client.ping().await?; let version = client.get_version().await?; @@ -300,6 +353,105 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> { "selection-details" => Command::SelectionDetails, "selection-raw" => Command::SelectionRaw, "netlist-pads" => Command::NetlistPads, + "items-by-id" => { + let item_ids = parse_item_ids(&args[1..], "items-by-id")?; + Command::ItemsById { item_ids } + } + "item-bbox" => { + let mut item_ids = Vec::new(); + let mut include_child_text = false; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--id" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for item-bbox --id".to_string(), + })?; + item_ids.push(value.clone()); + i += 2; + } + "--include-text" => { + include_child_text = true; + i += 1; + } + _ => { + i += 1; + } + } + } + + if item_ids.is_empty() { + return Err(KiCadError::Config { + reason: "item-bbox requires one or more `--id ` arguments".to_string(), + }); + } + + Command::ItemBBox { + item_ids, + include_child_text, + } + } + "hit-test" => { + let mut item_id = None; + let mut x_nm = None; + let mut y_nm = None; + let mut tolerance_nm = 0_i32; + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--id" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for hit-test --id".to_string(), + })?; + item_id = Some(value.clone()); + i += 2; + } + "--x-nm" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for hit-test --x-nm".to_string(), + })?; + x_nm = Some(value.parse::().map_err(|err| KiCadError::Config { + reason: format!("invalid hit-test --x-nm `{value}`: {err}"), + })?); + i += 2; + } + "--y-nm" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for hit-test --y-nm".to_string(), + })?; + y_nm = Some(value.parse::().map_err(|err| KiCadError::Config { + reason: format!("invalid hit-test --y-nm `{value}`: {err}"), + })?); + i += 2; + } + "--tolerance-nm" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for hit-test --tolerance-nm".to_string(), + })?; + tolerance_nm = value.parse::().map_err(|err| KiCadError::Config { + reason: format!("invalid hit-test --tolerance-nm `{value}`: {err}"), + })?; + i += 2; + } + _ => { + i += 1; + } + } + } + + Command::HitTest { + item_id: item_id.ok_or_else(|| KiCadError::Config { + reason: "hit-test requires `--id `".to_string(), + })?, + x_nm: x_nm.ok_or_else(|| KiCadError::Config { + reason: "hit-test requires `--x-nm `".to_string(), + })?, + y_nm: y_nm.ok_or_else(|| KiCadError::Config { + reason: "hit-test requires `--y-nm `".to_string(), + })?, + tolerance_nm, + } + } "smoke" => Command::Smoke, "open-docs" => { let mut document_type = DocumentType::Pcb; @@ -338,10 +490,34 @@ fn default_config() -> CliConfig { fn print_help() { println!( - "kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--timeout-ms N] [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type ] List open docs (default type: pcb)\n project-path Get current project path from open PCB docs\n board-open Exit non-zero if no PCB doc is open\n nets List board nets (requires one open PCB)\n netlist-pads Emit pad-level netlist data (with footprint context)\n enabled-layers List enabled board layers\n active-layer Show active board layer\n visible-layers Show currently visible board layers\n board-origin [--type ] Show board origin (`grid` default, or `drill`)\n selection-summary Show current selection item type counts\n selection-details Show parsed details for selected items\n selection-raw Show raw Any payload bytes for selected items\n smoke ping + version + board-open summary\n help Show help\n\nTYPES:\n schematic | symbol | pcb | footprint | drawing-sheet | project\n" + "kicad-ipc-cli\n\nUSAGE:\n cargo run --bin kicad-ipc-cli -- [--socket URI] [--token TOKEN] [--timeout-ms N] [command options]\n\nCOMMANDS:\n ping Check IPC connectivity\n version Fetch KiCad version\n open-docs [--type ] List open docs (default type: pcb)\n project-path Get current project path from open PCB docs\n board-open Exit non-zero if no PCB doc is open\n nets List board nets (requires one open PCB)\n netlist-pads Emit pad-level netlist data (with footprint context)\n items-by-id --id ... Show parsed details for specific item IDs\n item-bbox --id ... Show bounding boxes for item IDs\n hit-test --id --x-nm --y-nm [--tolerance-nm ]\n Hit-test one item at a point\n enabled-layers List enabled board layers\n active-layer Show active board layer\n visible-layers Show currently visible board layers\n board-origin [--type ] Show board origin (`grid` default, or `drill`)\n selection-summary Show current selection item type counts\n selection-details Show parsed details for selected items\n selection-raw Show raw Any payload bytes for selected items\n smoke ping + version + board-open summary\n help Show help\n\nTYPES:\n schematic | symbol | pcb | footprint | drawing-sheet | project\n" ); } +fn parse_item_ids(args: &[String], command_name: &str) -> Result, KiCadError> { + let mut item_ids = Vec::new(); + let mut i = 0; + while i < args.len() { + if args[i] == "--id" { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: format!("missing value for {command_name} --id"), + })?; + item_ids.push(value.clone()); + i += 2; + continue; + } + i += 1; + } + + if item_ids.is_empty() { + return Err(KiCadError::Config { + reason: format!("{command_name} requires one or more `--id ` arguments"), + }); + } + + Ok(item_ids) +} + fn bytes_to_hex(data: &[u8]) -> String { let mut output = String::with_capacity(data.len() * 2); for byte in data {