kicad-ipc-rs/src/client.rs

3119 lines
113 KiB
Rust

use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::envelope;
use crate::error::KiCadError;
use crate::model::board::{
ArcStartMidEndNm, BoardEditorAppearanceSettings, BoardEnabledLayers, BoardFlipMode,
BoardLayerClass, BoardLayerGraphicsDefault, BoardLayerInfo, BoardNet, BoardOriginKind,
BoardStackup, BoardStackupDielectricProperties, BoardStackupLayer, BoardStackupLayerType,
ColorRgba, GraphicsDefaults, InactiveLayerDisplayMode, NetClassBoardSettings,
NetClassForNetEntry, NetClassInfo, NetClassType, NetColorDisplayMode, PadNetEntry,
PadShapeAsPolygonEntry, PadstackPresenceEntry, PadstackPresenceState, PcbArc,
PcbBoardGraphicShape, PcbBoardText, PcbBoardTextBox, PcbDimension, PcbField, PcbFootprint,
PcbGroup, PcbItem, PcbPad, PcbPadType, PcbTrack, PcbUnknownItem, PcbVia, PcbViaType, PcbZone,
PcbZoneType, PolyLineNm, PolyLineNodeGeometryNm, PolygonWithHolesNm, RatsnestDisplayMode,
Vector2Nm,
};
use crate::model::common::{
DocumentSpecifier, DocumentType, ItemBoundingBox, ItemHitTestResult, PcbObjectTypeCode,
ProjectInfo, SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAsShapesEntry,
TextAttributesSpec, TextBoxSpec, TextExtents, TextHorizontalAlignment, TextObjectSpec,
TextShape, TextShapeGeometry, TextSpec, TextVerticalAlignment, TitleBlockInfo, VersionInfo,
};
use crate::proto::kiapi::board as board_proto;
use crate::proto::kiapi::board::commands as board_commands;
use crate::proto::kiapi::board::types as board_types;
use crate::proto::kiapi::common::commands as common_commands;
use crate::proto::kiapi::common::project as common_project;
use crate::proto::kiapi::common::types as common_types;
use crate::transport::Transport;
const KICAD_API_SOCKET_ENV: &str = "KICAD_API_SOCKET";
const KICAD_API_TOKEN_ENV: &str = "KICAD_API_TOKEN";
const CMD_PING: &str = "kiapi.common.commands.Ping";
const CMD_GET_VERSION: &str = "kiapi.common.commands.GetVersion";
const CMD_GET_NET_CLASSES: &str = "kiapi.common.commands.GetNetClasses";
const CMD_GET_TEXT_VARIABLES: &str = "kiapi.common.commands.GetTextVariables";
const CMD_EXPAND_TEXT_VARIABLES: &str = "kiapi.common.commands.ExpandTextVariables";
const CMD_GET_TEXT_EXTENTS: &str = "kiapi.common.commands.GetTextExtents";
const CMD_GET_TEXT_AS_SHAPES: &str = "kiapi.common.commands.GetTextAsShapes";
const CMD_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocuments";
const CMD_GET_NETS: &str = "kiapi.board.commands.GetNets";
const CMD_GET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.GetBoardEnabledLayers";
const CMD_GET_ACTIVE_LAYER: &str = "kiapi.board.commands.GetActiveLayer";
const CMD_GET_VISIBLE_LAYERS: &str = "kiapi.board.commands.GetVisibleLayers";
const CMD_GET_BOARD_ORIGIN: &str = "kiapi.board.commands.GetBoardOrigin";
const CMD_GET_BOARD_STACKUP: &str = "kiapi.board.commands.GetBoardStackup";
const CMD_GET_GRAPHICS_DEFAULTS: &str = "kiapi.board.commands.GetGraphicsDefaults";
const CMD_GET_BOARD_EDITOR_APPEARANCE_SETTINGS: &str =
"kiapi.board.commands.GetBoardEditorAppearanceSettings";
const CMD_GET_ITEMS_BY_NET: &str = "kiapi.board.commands.GetItemsByNet";
const CMD_GET_ITEMS_BY_NET_CLASS: &str = "kiapi.board.commands.GetItemsByNetClass";
const CMD_GET_NETCLASS_FOR_NETS: &str = "kiapi.board.commands.GetNetClassForNets";
const CMD_GET_PAD_SHAPE_AS_POLYGON: &str = "kiapi.board.commands.GetPadShapeAsPolygon";
const CMD_CHECK_PADSTACK_PRESENCE_ON_LAYERS: &str =
"kiapi.board.commands.CheckPadstackPresenceOnLayers";
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 CMD_GET_TITLE_BLOCK_INFO: &str = "kiapi.common.commands.GetTitleBlockInfo";
const CMD_SAVE_DOCUMENT_TO_STRING: &str = "kiapi.common.commands.SaveDocumentToString";
const CMD_SAVE_SELECTION_TO_STRING: &str = "kiapi.common.commands.SaveSelectionToString";
const RES_GET_VERSION: &str = "kiapi.common.commands.GetVersionResponse";
const RES_NET_CLASSES_RESPONSE: &str = "kiapi.common.commands.NetClassesResponse";
const RES_TEXT_VARIABLES: &str = "kiapi.common.project.TextVariables";
const RES_EXPAND_TEXT_VARIABLES_RESPONSE: &str =
"kiapi.common.commands.ExpandTextVariablesResponse";
const RES_BOX2: &str = "kiapi.common.types.Box2";
const RES_GET_TEXT_AS_SHAPES_RESPONSE: &str = "kiapi.common.commands.GetTextAsShapesResponse";
const RES_GET_OPEN_DOCUMENTS: &str = "kiapi.common.commands.GetOpenDocumentsResponse";
const RES_GET_NETS: &str = "kiapi.board.commands.NetsResponse";
const RES_GET_BOARD_ENABLED_LAYERS: &str = "kiapi.board.commands.BoardEnabledLayersResponse";
const RES_BOARD_LAYER_RESPONSE: &str = "kiapi.board.commands.BoardLayerResponse";
const RES_BOARD_LAYERS: &str = "kiapi.board.commands.BoardLayers";
const RES_BOARD_STACKUP_RESPONSE: &str = "kiapi.board.commands.BoardStackupResponse";
const RES_GRAPHICS_DEFAULTS_RESPONSE: &str = "kiapi.board.commands.GraphicsDefaultsResponse";
const RES_BOARD_EDITOR_APPEARANCE_SETTINGS: &str =
"kiapi.board.commands.BoardEditorAppearanceSettings";
const RES_NETCLASS_FOR_NETS_RESPONSE: &str = "kiapi.board.commands.NetClassForNetsResponse";
const RES_PAD_SHAPE_AS_POLYGON_RESPONSE: &str = "kiapi.board.commands.PadShapeAsPolygonResponse";
const RES_PADSTACK_PRESENCE_RESPONSE: &str = "kiapi.board.commands.PadstackPresenceResponse";
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";
const RES_TITLE_BLOCK_INFO: &str = "kiapi.common.types.TitleBlockInfo";
const RES_SAVED_DOCUMENT_RESPONSE: &str = "kiapi.common.commands.SavedDocumentResponse";
const RES_SAVED_SELECTION_RESPONSE: &str = "kiapi.common.commands.SavedSelectionResponse";
const PAD_QUERY_CHUNK_SIZE: usize = 256;
const PCB_OBJECT_TYPES: [PcbObjectTypeCode; 18] = [
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbFootprint as i32,
name: "KOT_PCB_FOOTPRINT",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbPad as i32,
name: "KOT_PCB_PAD",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbShape as i32,
name: "KOT_PCB_SHAPE",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbReferenceImage as i32,
name: "KOT_PCB_REFERENCE_IMAGE",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbField as i32,
name: "KOT_PCB_FIELD",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbGenerator as i32,
name: "KOT_PCB_GENERATOR",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbText as i32,
name: "KOT_PCB_TEXT",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbTextbox as i32,
name: "KOT_PCB_TEXTBOX",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbTable as i32,
name: "KOT_PCB_TABLE",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbTablecell as i32,
name: "KOT_PCB_TABLECELL",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbTrace as i32,
name: "KOT_PCB_TRACE",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbVia as i32,
name: "KOT_PCB_VIA",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbArc as i32,
name: "KOT_PCB_ARC",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbMarker as i32,
name: "KOT_PCB_MARKER",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbDimension as i32,
name: "KOT_PCB_DIMENSION",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbZone as i32,
name: "KOT_PCB_ZONE",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbGroup as i32,
name: "KOT_PCB_GROUP",
},
PcbObjectTypeCode {
code: common_types::KiCadObjectType::KotPcbBarcode as i32,
name: "KOT_PCB_BARCODE",
},
];
#[derive(Clone, Debug)]
pub struct KiCadClient {
inner: Arc<ClientInner>,
}
#[derive(Debug)]
struct ClientInner {
transport: Transport,
token: Mutex<String>,
client_name: String,
timeout: Duration,
socket_uri: String,
}
#[derive(Clone, Debug)]
struct ClientConfig {
timeout: Duration,
socket_uri: Option<String>,
token: Option<String>,
client_name: Option<String>,
}
#[derive(Clone, Debug)]
pub struct ClientBuilder {
config: ClientConfig,
}
impl ClientBuilder {
pub fn new() -> Self {
Self {
config: ClientConfig {
timeout: Duration::from_millis(3_000),
socket_uri: None,
token: None,
client_name: None,
},
}
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.config.timeout = timeout;
self
}
pub fn socket_path(mut self, socket_path: impl Into<String>) -> Self {
self.config.socket_uri = Some(socket_path.into());
self
}
pub fn token(mut self, token: impl Into<String>) -> Self {
self.config.token = Some(token.into());
self
}
pub fn client_name(mut self, client_name: impl Into<String>) -> Self {
self.config.client_name = Some(client_name.into());
self
}
pub async fn connect(self) -> Result<KiCadClient, KiCadError> {
let socket_uri = resolve_socket_uri(self.config.socket_uri.as_deref());
if is_missing_ipc_socket(&socket_uri) {
return Err(KiCadError::SocketUnavailable { socket_uri });
}
let timeout = self.config.timeout;
let transport = Transport::connect(&socket_uri, timeout)?;
let token = self
.config
.token
.or_else(|| std::env::var(KICAD_API_TOKEN_ENV).ok())
.unwrap_or_default();
let client_name = self.config.client_name.unwrap_or_else(default_client_name);
Ok(KiCadClient {
inner: Arc::new(ClientInner {
transport,
token: Mutex::new(token),
client_name,
timeout,
socket_uri,
}),
})
}
}
impl Default for ClientBuilder {
fn default() -> Self {
Self::new()
}
}
impl KiCadClient {
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
pub async fn connect() -> Result<Self, KiCadError> {
ClientBuilder::new().connect().await
}
pub fn timeout(&self) -> Duration {
self.inner.timeout
}
pub fn socket_uri(&self) -> &str {
&self.inner.socket_uri
}
pub async fn ping(&self) -> Result<(), KiCadError> {
let command = envelope::pack_any(&common_commands::Ping {}, CMD_PING);
self.send_command(command).await?;
Ok(())
}
pub async fn get_version(&self) -> Result<VersionInfo, KiCadError> {
let command = envelope::pack_any(&common_commands::GetVersion {}, CMD_GET_VERSION);
let response = self.send_command(command).await?;
let payload: common_commands::GetVersionResponse =
envelope::unpack_any(&response, RES_GET_VERSION)?;
let version = payload.version.ok_or_else(|| KiCadError::MissingPayload {
expected_type_url: "kiapi.common.types.KiCadVersion".to_string(),
})?;
Ok(VersionInfo {
major: version.major,
minor: version.minor,
patch: version.patch,
full_version: version.full_version,
})
}
pub async fn get_open_documents(
&self,
document_type: DocumentType,
) -> Result<Vec<DocumentSpecifier>, KiCadError> {
let command = common_commands::GetOpenDocuments {
r#type: document_type.to_proto(),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_OPEN_DOCUMENTS))
.await?;
let payload: common_commands::GetOpenDocumentsResponse =
envelope::unpack_any(&response, RES_GET_OPEN_DOCUMENTS)?;
Ok(payload
.documents
.into_iter()
.filter_map(map_document_specifier)
.collect())
}
pub async fn get_net_classes_raw(&self) -> Result<prost_types::Any, KiCadError> {
let command = common_commands::GetNetClasses {};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_NET_CLASSES))
.await?;
response_payload_as_any(response, RES_NET_CLASSES_RESPONSE)
}
pub async fn get_net_classes(&self) -> Result<Vec<NetClassInfo>, KiCadError> {
let payload = self.get_net_classes_raw().await?;
let response: common_commands::NetClassesResponse =
decode_any(&payload, RES_NET_CLASSES_RESPONSE)?;
let mut classes: Vec<NetClassInfo> = response
.net_classes
.into_iter()
.map(map_net_class_info)
.collect();
classes.sort_by(|left, right| left.name.cmp(&right.name));
Ok(classes)
}
pub async fn get_text_variables_raw(&self) -> Result<prost_types::Any, KiCadError> {
let command = common_commands::GetTextVariables {
document: Some(self.current_board_document_proto().await?),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_TEXT_VARIABLES))
.await?;
response_payload_as_any(response, RES_TEXT_VARIABLES)
}
pub async fn get_text_variables(&self) -> Result<BTreeMap<String, String>, KiCadError> {
let payload = self.get_text_variables_raw().await?;
let response: common_project::TextVariables = decode_any(&payload, RES_TEXT_VARIABLES)?;
Ok(response.variables.into_iter().collect())
}
pub async fn expand_text_variables_raw(
&self,
text: Vec<String>,
) -> Result<prost_types::Any, KiCadError> {
let command = common_commands::ExpandTextVariables {
document: Some(self.current_board_document_proto().await?),
text,
};
let response = self
.send_command(envelope::pack_any(&command, CMD_EXPAND_TEXT_VARIABLES))
.await?;
response_payload_as_any(response, RES_EXPAND_TEXT_VARIABLES_RESPONSE)
}
pub async fn expand_text_variables(
&self,
text: Vec<String>,
) -> Result<Vec<String>, KiCadError> {
let payload = self.expand_text_variables_raw(text).await?;
let response: common_commands::ExpandTextVariablesResponse =
decode_any(&payload, RES_EXPAND_TEXT_VARIABLES_RESPONSE)?;
Ok(response.text)
}
pub async fn get_text_extents_raw(
&self,
text: TextSpec,
) -> Result<prost_types::Any, KiCadError> {
let command = common_commands::GetTextExtents {
text: Some(text_spec_to_proto(text)),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_TEXT_EXTENTS))
.await?;
response_payload_as_any(response, RES_BOX2)
}
pub async fn get_text_extents(&self, text: TextSpec) -> Result<TextExtents, KiCadError> {
let payload = self.get_text_extents_raw(text).await?;
let response: common_types::Box2 = decode_any(&payload, RES_BOX2)?;
let position = response
.position
.ok_or_else(|| KiCadError::InvalidResponse {
reason: "GetTextExtents response missing position".to_string(),
})?;
let size = response.size.ok_or_else(|| KiCadError::InvalidResponse {
reason: "GetTextExtents response missing size".to_string(),
})?;
Ok(TextExtents {
x_nm: position.x_nm,
y_nm: position.y_nm,
width_nm: size.x_nm,
height_nm: size.y_nm,
})
}
pub async fn get_text_as_shapes_raw(
&self,
text: Vec<TextObjectSpec>,
) -> Result<prost_types::Any, KiCadError> {
let command = common_commands::GetTextAsShapes {
text: text.into_iter().map(text_object_spec_to_proto).collect(),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_TEXT_AS_SHAPES))
.await?;
response_payload_as_any(response, RES_GET_TEXT_AS_SHAPES_RESPONSE)
}
pub async fn get_text_as_shapes(
&self,
text: Vec<TextObjectSpec>,
) -> Result<Vec<TextAsShapesEntry>, KiCadError> {
let payload = self.get_text_as_shapes_raw(text).await?;
let response: common_commands::GetTextAsShapesResponse =
decode_any(&payload, RES_GET_TEXT_AS_SHAPES_RESPONSE)?;
response
.text_with_shapes
.into_iter()
.map(map_text_with_shapes)
.collect()
}
pub async fn get_current_project_path(&self) -> Result<PathBuf, KiCadError> {
let docs = self.get_open_documents(DocumentType::Pcb).await?;
select_single_project_path(&docs)
}
pub async fn has_open_board(&self) -> Result<bool, KiCadError> {
let docs = self.get_open_documents(DocumentType::Pcb).await?;
Ok(!docs.is_empty())
}
pub async fn get_nets(&self) -> Result<Vec<BoardNet>, KiCadError> {
let board = self.current_board_document_proto().await?;
let command = board_commands::GetNets {
board: Some(board),
netclass_filter: Vec::new(),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_NETS))
.await?;
let payload: board_commands::NetsResponse = envelope::unpack_any(&response, RES_GET_NETS)?;
Ok(payload
.nets
.into_iter()
.map(|net| BoardNet {
code: net.code.map_or(0, |code| code.value),
name: net.name,
})
.collect())
}
pub async fn get_board_enabled_layers(&self) -> Result<BoardEnabledLayers, KiCadError> {
let board = self.current_board_document_proto().await?;
let command = board_commands::GetBoardEnabledLayers { board: Some(board) };
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_BOARD_ENABLED_LAYERS))
.await?;
let payload: board_commands::BoardEnabledLayersResponse =
envelope::unpack_any(&response, RES_GET_BOARD_ENABLED_LAYERS)?;
Ok(BoardEnabledLayers {
copper_layer_count: payload.copper_layer_count,
layers: payload.layers.into_iter().map(layer_to_model).collect(),
})
}
pub async fn get_active_layer(&self) -> Result<BoardLayerInfo, KiCadError> {
let board = self.current_board_document_proto().await?;
let command = board_commands::GetActiveLayer { board: Some(board) };
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_ACTIVE_LAYER))
.await?;
let payload: board_commands::BoardLayerResponse =
envelope::unpack_any(&response, RES_BOARD_LAYER_RESPONSE)?;
Ok(layer_to_model(payload.layer))
}
pub async fn get_visible_layers(&self) -> Result<Vec<BoardLayerInfo>, KiCadError> {
let board = self.current_board_document_proto().await?;
let command = board_commands::GetVisibleLayers { board: Some(board) };
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_VISIBLE_LAYERS))
.await?;
let payload: board_commands::BoardLayers =
envelope::unpack_any(&response, RES_BOARD_LAYERS)?;
Ok(payload.layers.into_iter().map(layer_to_model).collect())
}
pub async fn get_board_origin(&self, kind: BoardOriginKind) -> Result<Vector2Nm, KiCadError> {
let board = self.current_board_document_proto().await?;
let command = board_commands::GetBoardOrigin {
board: Some(board),
r#type: board_origin_kind_to_proto(kind),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_BOARD_ORIGIN))
.await?;
let payload: common_types::Vector2 = envelope::unpack_any(&response, RES_VECTOR2)?;
Ok(Vector2Nm {
x_nm: payload.x_nm,
y_nm: payload.y_nm,
})
}
pub async fn get_selection_summary(&self) -> Result<SelectionSummary, 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,
}),
types: Vec::new(),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_SELECTION))
.await?;
let payload: common_commands::SelectionResponse =
envelope::unpack_any(&response, RES_SELECTION_RESPONSE)?;
Ok(summarize_selection(payload.items))
}
pub async fn get_selection_raw(&self) -> Result<Vec<prost_types::Any>, KiCadError> {
let command = common_commands::GetSelection {
header: Some(self.current_board_item_header().await?),
types: Vec::new(),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_SELECTION))
.await?;
let payload: common_commands::SelectionResponse =
envelope::unpack_any(&response, RES_SELECTION_RESPONSE)?;
Ok(payload.items)
}
pub async fn get_selection_details(&self) -> Result<Vec<SelectionItemDetail>, KiCadError> {
let items = self.get_selection_raw().await?;
summarize_item_details(items)
}
pub async fn get_selection(&self) -> Result<Vec<PcbItem>, KiCadError> {
let items = self.get_selection_raw().await?;
decode_pcb_items(items)
}
pub async fn get_pad_netlist(&self) -> Result<Vec<PadNetEntry>, KiCadError> {
let footprint_items = self
.get_items_raw(vec![common_types::KiCadObjectType::KotPcbFootprint as i32])
.await?;
pad_netlist_from_footprint_items(footprint_items)
}
pub fn pcb_object_type_codes() -> &'static [PcbObjectTypeCode] {
&PCB_OBJECT_TYPES
}
pub fn pcb_object_type_name(type_code: i32) -> Option<&'static str> {
PCB_OBJECT_TYPES
.iter()
.find(|entry| entry.code == type_code)
.map(|entry| entry.name)
}
pub fn debug_any_item(item: &prost_types::Any) -> Result<String, KiCadError> {
any_to_pretty_debug(item)
}
pub async fn get_items_raw_by_type_codes(
&self,
type_codes: Vec<i32>,
) -> Result<Vec<prost_types::Any>, KiCadError> {
self.get_items_raw(type_codes).await
}
pub async fn get_items_details_by_type_codes(
&self,
type_codes: Vec<i32>,
) -> Result<Vec<SelectionItemDetail>, KiCadError> {
let items = self.get_items_raw(type_codes).await?;
summarize_item_details(items)
}
pub async fn get_items_by_type_codes(
&self,
type_codes: Vec<i32>,
) -> Result<Vec<PcbItem>, KiCadError> {
let items = self.get_items_raw(type_codes).await?;
decode_pcb_items(items)
}
pub async fn get_all_pcb_items_raw(
&self,
) -> Result<Vec<(PcbObjectTypeCode, Vec<prost_types::Any>)>, KiCadError> {
let mut rows = Vec::with_capacity(PCB_OBJECT_TYPES.len());
for object_type in PCB_OBJECT_TYPES {
let items = self.get_items_raw(vec![object_type.code]).await?;
rows.push((object_type, items));
}
Ok(rows)
}
pub async fn get_all_pcb_items_details(
&self,
) -> Result<Vec<(PcbObjectTypeCode, Vec<SelectionItemDetail>)>, KiCadError> {
let mut rows = Vec::with_capacity(PCB_OBJECT_TYPES.len());
for object_type in PCB_OBJECT_TYPES {
let items = self.get_items_raw(vec![object_type.code]).await?;
rows.push((object_type, summarize_item_details(items)?));
}
Ok(rows)
}
pub async fn get_all_pcb_items(
&self,
) -> Result<Vec<(PcbObjectTypeCode, Vec<PcbItem>)>, KiCadError> {
let mut rows = Vec::with_capacity(PCB_OBJECT_TYPES.len());
for object_type in PCB_OBJECT_TYPES {
let items = self.get_items_raw(vec![object_type.code]).await?;
rows.push((object_type, decode_pcb_items(items)?));
}
Ok(rows)
}
pub async fn get_items_by_net_raw(
&self,
type_codes: Vec<i32>,
net_codes: Vec<i32>,
) -> Result<Vec<prost_types::Any>, KiCadError> {
let command = board_commands::GetItemsByNet {
header: Some(self.current_board_item_header().await?),
types: type_codes,
net_codes: net_codes
.into_iter()
.map(|value| board_types::NetCode { value })
.collect(),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_ITEMS_BY_NET))
.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_net(
&self,
type_codes: Vec<i32>,
net_codes: Vec<i32>,
) -> Result<Vec<PcbItem>, KiCadError> {
let items = self.get_items_by_net_raw(type_codes, net_codes).await?;
decode_pcb_items(items)
}
pub async fn get_items_by_net_class_raw(
&self,
type_codes: Vec<i32>,
net_classes: Vec<String>,
) -> Result<Vec<prost_types::Any>, KiCadError> {
let command = board_commands::GetItemsByNetClass {
header: Some(self.current_board_item_header().await?),
types: type_codes,
net_classes,
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_ITEMS_BY_NET_CLASS))
.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_net_class(
&self,
type_codes: Vec<i32>,
net_classes: Vec<String>,
) -> Result<Vec<PcbItem>, KiCadError> {
let items = self
.get_items_by_net_class_raw(type_codes, net_classes)
.await?;
decode_pcb_items(items)
}
pub async fn get_netclass_for_nets_raw(
&self,
nets: Vec<BoardNet>,
) -> Result<prost_types::Any, KiCadError> {
let command = board_commands::GetNetClassForNets {
net: nets
.into_iter()
.map(|net| board_types::Net {
code: Some(board_types::NetCode { value: net.code }),
name: net.name,
})
.collect(),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_NETCLASS_FOR_NETS))
.await?;
response_payload_as_any(response, RES_NETCLASS_FOR_NETS_RESPONSE)
}
pub async fn get_netclass_for_nets(
&self,
nets: Vec<BoardNet>,
) -> Result<Vec<NetClassForNetEntry>, KiCadError> {
let payload = self.get_netclass_for_nets_raw(nets).await?;
let response: board_commands::NetClassForNetsResponse =
decode_any(&payload, RES_NETCLASS_FOR_NETS_RESPONSE)?;
Ok(map_netclass_for_nets_response(response))
}
pub async fn get_pad_shape_as_polygon_raw(
&self,
pad_ids: Vec<String>,
layer_id: i32,
) -> Result<Vec<prost_types::Any>, KiCadError> {
if pad_ids.is_empty() {
return Ok(Vec::new());
}
let board = self.current_board_document_proto().await?;
let mut payloads = Vec::new();
for chunk in pad_ids.chunks(PAD_QUERY_CHUNK_SIZE) {
let command = board_commands::GetPadShapeAsPolygon {
board: Some(board.clone()),
pads: chunk
.iter()
.cloned()
.map(|value| common_types::Kiid { value })
.collect(),
layer: layer_id,
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_PAD_SHAPE_AS_POLYGON))
.await?;
payloads.push(response_payload_as_any(
response,
RES_PAD_SHAPE_AS_POLYGON_RESPONSE,
)?);
}
Ok(payloads)
}
pub async fn get_pad_shape_as_polygon(
&self,
pad_ids: Vec<String>,
layer_id: i32,
) -> Result<Vec<PadShapeAsPolygonEntry>, KiCadError> {
if pad_ids.is_empty() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
let layer_name = layer_to_model(layer_id).name;
let payloads = self.get_pad_shape_as_polygon_raw(pad_ids, layer_id).await?;
for payload in payloads {
let payload: board_commands::PadShapeAsPolygonResponse =
decode_any(&payload, RES_PAD_SHAPE_AS_POLYGON_RESPONSE)?;
if payload.pads.len() != payload.polygons.len() {
return Err(KiCadError::InvalidResponse {
reason: format!(
"GetPadShapeAsPolygon returned mismatched arrays: pads={}, polygons={}",
payload.pads.len(),
payload.polygons.len()
),
});
}
for (pad, polygon) in payload.pads.into_iter().zip(payload.polygons.into_iter()) {
entries.push(PadShapeAsPolygonEntry {
pad_id: pad.value,
layer_id,
layer_name: layer_name.clone(),
polygon: map_polygon_with_holes(polygon)?,
});
}
}
Ok(entries)
}
pub async fn check_padstack_presence_on_layers_raw(
&self,
item_ids: Vec<String>,
layer_ids: Vec<i32>,
) -> Result<Vec<prost_types::Any>, KiCadError> {
if item_ids.is_empty() || layer_ids.is_empty() {
return Ok(Vec::new());
}
let board = self.current_board_document_proto().await?;
let mut payloads = Vec::new();
for chunk in item_ids.chunks(PAD_QUERY_CHUNK_SIZE) {
let command = board_commands::CheckPadstackPresenceOnLayers {
board: Some(board.clone()),
items: chunk
.iter()
.cloned()
.map(|value| common_types::Kiid { value })
.collect(),
layers: layer_ids.clone(),
};
let response = self
.send_command(envelope::pack_any(
&command,
CMD_CHECK_PADSTACK_PRESENCE_ON_LAYERS,
))
.await?;
payloads.push(response_payload_as_any(
response,
RES_PADSTACK_PRESENCE_RESPONSE,
)?);
}
Ok(payloads)
}
pub async fn check_padstack_presence_on_layers(
&self,
item_ids: Vec<String>,
layer_ids: Vec<i32>,
) -> Result<Vec<PadstackPresenceEntry>, KiCadError> {
if item_ids.is_empty() || layer_ids.is_empty() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
let payloads = self
.check_padstack_presence_on_layers_raw(item_ids, layer_ids)
.await?;
for payload in payloads {
let payload: board_commands::PadstackPresenceResponse =
decode_any(&payload, RES_PADSTACK_PRESENCE_RESPONSE)?;
for row in payload.entries {
let item = row.item.ok_or_else(|| KiCadError::InvalidResponse {
reason: "PadstackPresenceEntry missing item id".to_string(),
})?;
let layer = layer_to_model(row.layer);
let presence = map_padstack_presence(row.presence);
entries.push(PadstackPresenceEntry {
item_id: item.value,
layer_id: row.layer,
layer_name: layer.name,
presence,
});
}
}
Ok(entries)
}
pub async fn get_board_stackup_raw(&self) -> Result<prost_types::Any, KiCadError> {
let command = board_commands::GetBoardStackup {
board: Some(self.current_board_document_proto().await?),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_BOARD_STACKUP))
.await?;
response_payload_as_any(response, RES_BOARD_STACKUP_RESPONSE)
}
pub async fn get_board_stackup(&self) -> Result<BoardStackup, KiCadError> {
let payload = self.get_board_stackup_raw().await?;
let response: board_commands::BoardStackupResponse =
decode_any(&payload, RES_BOARD_STACKUP_RESPONSE)?;
Ok(map_board_stackup(response.stackup.unwrap_or_default()))
}
pub async fn get_graphics_defaults_raw(&self) -> Result<prost_types::Any, KiCadError> {
let command = board_commands::GetGraphicsDefaults {
board: Some(self.current_board_document_proto().await?),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_GRAPHICS_DEFAULTS))
.await?;
response_payload_as_any(response, RES_GRAPHICS_DEFAULTS_RESPONSE)
}
pub async fn get_graphics_defaults(&self) -> Result<GraphicsDefaults, KiCadError> {
let payload = self.get_graphics_defaults_raw().await?;
let response: board_commands::GraphicsDefaultsResponse =
decode_any(&payload, RES_GRAPHICS_DEFAULTS_RESPONSE)?;
Ok(map_graphics_defaults(response.defaults.unwrap_or_default()))
}
pub async fn get_board_editor_appearance_settings_raw(
&self,
) -> Result<prost_types::Any, KiCadError> {
let command = board_commands::GetBoardEditorAppearanceSettings {};
let response = self
.send_command(envelope::pack_any(
&command,
CMD_GET_BOARD_EDITOR_APPEARANCE_SETTINGS,
))
.await?;
response_payload_as_any(response, RES_BOARD_EDITOR_APPEARANCE_SETTINGS)
}
pub async fn get_board_editor_appearance_settings(
&self,
) -> Result<BoardEditorAppearanceSettings, KiCadError> {
let payload = self.get_board_editor_appearance_settings_raw().await?;
let response: board_commands::BoardEditorAppearanceSettings =
decode_any(&payload, RES_BOARD_EDITOR_APPEARANCE_SETTINGS)?;
Ok(map_board_editor_appearance_settings(response))
}
pub async fn get_title_block_info(&self) -> Result<TitleBlockInfo, KiCadError> {
let command = common_commands::GetTitleBlockInfo {
document: Some(self.current_board_document_proto().await?),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_TITLE_BLOCK_INFO))
.await?;
let payload: common_types::TitleBlockInfo =
envelope::unpack_any(&response, RES_TITLE_BLOCK_INFO)?;
let comments = vec![
payload.comment1,
payload.comment2,
payload.comment3,
payload.comment4,
payload.comment5,
payload.comment6,
payload.comment7,
payload.comment8,
payload.comment9,
]
.into_iter()
.filter(|comment| !comment.is_empty())
.collect();
Ok(TitleBlockInfo {
title: payload.title,
date: payload.date,
revision: payload.revision,
company: payload.company,
comments,
})
}
pub async fn get_board_as_string(&self) -> Result<String, KiCadError> {
let command = common_commands::SaveDocumentToString {
document: Some(self.current_board_document_proto().await?),
};
let response = self
.send_command(envelope::pack_any(&command, CMD_SAVE_DOCUMENT_TO_STRING))
.await?;
let payload: common_commands::SavedDocumentResponse =
envelope::unpack_any(&response, RES_SAVED_DOCUMENT_RESPONSE)?;
Ok(payload.contents)
}
pub async fn get_selection_as_string(&self) -> Result<String, KiCadError> {
let command = common_commands::SaveSelectionToString {};
let response = self
.send_command(envelope::pack_any(&command, CMD_SAVE_SELECTION_TO_STRING))
.await?;
let payload: common_commands::SavedSelectionResponse =
envelope::unpack_any(&response, RES_SAVED_SELECTION_RESPONSE)?;
Ok(payload.contents)
}
pub async fn get_items_by_id_raw(
&self,
item_ids: Vec<String>,
) -> Result<Vec<prost_types::Any>, 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<String>,
) -> Result<Vec<SelectionItemDetail>, KiCadError> {
let items = self.get_items_by_id_raw(item_ids).await?;
summarize_item_details(items)
}
pub async fn get_items_by_id(&self, item_ids: Vec<String>) -> Result<Vec<PcbItem>, KiCadError> {
let items = self.get_items_by_id_raw(item_ids).await?;
decode_pcb_items(items)
}
pub async fn get_item_bounding_boxes(
&self,
item_ids: Vec<String>,
include_child_text: bool,
) -> Result<Vec<ItemBoundingBox>, 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<ItemHitTestResult, KiCadError> {
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,
) -> Result<crate::proto::kiapi::common::ApiResponse, KiCadError> {
let token = self
.inner
.token
.lock()
.map_err(|_| KiCadError::InternalPoisoned)?
.clone();
let request_bytes = envelope::encode_request(&token, &self.inner.client_name, command)?;
let response_bytes = self.inner.transport.roundtrip(request_bytes).await?;
let response = envelope::decode_response(&response_bytes)?;
if let Some(err) = envelope::status_error(&response) {
return Err(err);
}
if token.is_empty() {
if let Some(header) = response.header.as_ref() {
if !header.kicad_token.is_empty() {
let mut guard = self
.inner
.token
.lock()
.map_err(|_| KiCadError::InternalPoisoned)?;
*guard = header.kicad_token.clone();
}
}
}
Ok(response)
}
async fn current_board_document_proto(
&self,
) -> Result<common_types::DocumentSpecifier, KiCadError> {
let docs = self.get_open_documents(DocumentType::Pcb).await?;
let selected = select_single_board_document(&docs)?;
Ok(model_document_to_proto(selected))
}
async fn current_board_item_header(&self) -> Result<common_types::ItemHeader, KiCadError> {
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<i32>) -> Result<Vec<prost_types::Any>, KiCadError> {
let command = common_commands::GetItems {
header: Some(self.current_board_item_header().await?),
types,
};
let response = self
.send_command(envelope::pack_any(&command, CMD_GET_ITEMS))
.await?;
let payload: common_commands::GetItemsResponse =
envelope::unpack_any(&response, RES_GET_ITEMS_RESPONSE)?;
ensure_item_request_ok(payload.status)?;
Ok(payload.items)
}
}
fn map_document_specifier(source: common_types::DocumentSpecifier) -> Option<DocumentSpecifier> {
let document_type = DocumentType::from_proto(source.r#type)?;
let board_filename = match source.identifier {
Some(common_types::document_specifier::Identifier::BoardFilename(filename)) => {
Some(filename)
}
_ => None,
};
let project = source.project.unwrap_or_default();
let project_info = ProjectInfo {
name: if project.name.is_empty() {
None
} else {
Some(project.name)
},
path: if project.path.is_empty() {
None
} else {
Some(PathBuf::from(project.path))
},
};
Some(DocumentSpecifier {
document_type,
board_filename,
project: project_info,
})
}
fn model_document_to_proto(document: &DocumentSpecifier) -> common_types::DocumentSpecifier {
let identifier = document.board_filename.as_ref().map(|filename| {
common_types::document_specifier::Identifier::BoardFilename(filename.clone())
});
let project = common_types::ProjectSpecifier {
name: document.project.name.clone().unwrap_or_default(),
path: document
.project
.path
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_default(),
};
common_types::DocumentSpecifier {
r#type: document.document_type.to_proto(),
project: Some(project),
identifier,
}
}
fn text_spec_to_proto(text: TextSpec) -> common_types::Text {
common_types::Text {
position: text.position_nm.map(vector2_nm_to_proto),
attributes: text.attributes.map(text_attributes_spec_to_proto),
text: text.text,
hyperlink: text.hyperlink.unwrap_or_default(),
}
}
fn text_attributes_spec_to_proto(attributes: TextAttributesSpec) -> common_types::TextAttributes {
common_types::TextAttributes {
font_name: attributes.font_name.unwrap_or_default(),
horizontal_alignment: text_horizontal_alignment_to_proto(attributes.horizontal_alignment),
vertical_alignment: text_vertical_alignment_to_proto(attributes.vertical_alignment),
angle: attributes
.angle_degrees
.map(|value_degrees| common_types::Angle { value_degrees }),
line_spacing: attributes.line_spacing.unwrap_or(1.0),
stroke_width: attributes
.stroke_width_nm
.map(|value_nm| common_types::Distance { value_nm }),
italic: attributes.italic,
bold: attributes.bold,
underlined: attributes.underlined,
visible: true,
mirrored: attributes.mirrored,
multiline: attributes.multiline,
keep_upright: attributes.keep_upright,
size: attributes.size_nm.map(vector2_nm_to_proto),
}
}
fn text_horizontal_alignment_to_proto(value: TextHorizontalAlignment) -> i32 {
match value {
TextHorizontalAlignment::Unknown => common_types::HorizontalAlignment::HaUnknown as i32,
TextHorizontalAlignment::Left => common_types::HorizontalAlignment::HaLeft as i32,
TextHorizontalAlignment::Center => common_types::HorizontalAlignment::HaCenter as i32,
TextHorizontalAlignment::Right => common_types::HorizontalAlignment::HaRight as i32,
TextHorizontalAlignment::Indeterminate => {
common_types::HorizontalAlignment::HaIndeterminate as i32
}
}
}
fn text_vertical_alignment_to_proto(value: TextVerticalAlignment) -> i32 {
match value {
TextVerticalAlignment::Unknown => common_types::VerticalAlignment::VaUnknown as i32,
TextVerticalAlignment::Top => common_types::VerticalAlignment::VaTop as i32,
TextVerticalAlignment::Center => common_types::VerticalAlignment::VaCenter as i32,
TextVerticalAlignment::Bottom => common_types::VerticalAlignment::VaBottom as i32,
TextVerticalAlignment::Indeterminate => {
common_types::VerticalAlignment::VaIndeterminate as i32
}
}
}
fn text_box_spec_to_proto(text: TextBoxSpec) -> common_types::TextBox {
common_types::TextBox {
top_left: text.top_left_nm.map(vector2_nm_to_proto),
bottom_right: text.bottom_right_nm.map(vector2_nm_to_proto),
attributes: text.attributes.map(text_attributes_spec_to_proto),
text: text.text,
}
}
fn text_object_spec_to_proto(text: TextObjectSpec) -> common_commands::TextOrTextBox {
let inner = match text {
TextObjectSpec::Text(value) => {
common_commands::text_or_text_box::Inner::Text(text_spec_to_proto(value))
}
TextObjectSpec::TextBox(value) => {
common_commands::text_or_text_box::Inner::Textbox(text_box_spec_to_proto(value))
}
};
common_commands::TextOrTextBox { inner: Some(inner) }
}
fn map_text_horizontal_alignment_from_proto(value: i32) -> TextHorizontalAlignment {
match common_types::HorizontalAlignment::try_from(value) {
Ok(common_types::HorizontalAlignment::HaLeft) => TextHorizontalAlignment::Left,
Ok(common_types::HorizontalAlignment::HaCenter) => TextHorizontalAlignment::Center,
Ok(common_types::HorizontalAlignment::HaRight) => TextHorizontalAlignment::Right,
Ok(common_types::HorizontalAlignment::HaIndeterminate) => {
TextHorizontalAlignment::Indeterminate
}
_ => TextHorizontalAlignment::Unknown,
}
}
fn map_text_vertical_alignment_from_proto(value: i32) -> TextVerticalAlignment {
match common_types::VerticalAlignment::try_from(value) {
Ok(common_types::VerticalAlignment::VaTop) => TextVerticalAlignment::Top,
Ok(common_types::VerticalAlignment::VaCenter) => TextVerticalAlignment::Center,
Ok(common_types::VerticalAlignment::VaBottom) => TextVerticalAlignment::Bottom,
Ok(common_types::VerticalAlignment::VaIndeterminate) => {
TextVerticalAlignment::Indeterminate
}
_ => TextVerticalAlignment::Unknown,
}
}
fn map_text_attributes_spec_from_proto(
attributes: common_types::TextAttributes,
) -> TextAttributesSpec {
TextAttributesSpec {
font_name: if attributes.font_name.is_empty() {
None
} else {
Some(attributes.font_name)
},
horizontal_alignment: map_text_horizontal_alignment_from_proto(
attributes.horizontal_alignment,
),
vertical_alignment: map_text_vertical_alignment_from_proto(attributes.vertical_alignment),
angle_degrees: attributes.angle.map(|value| value.value_degrees),
line_spacing: Some(attributes.line_spacing),
stroke_width_nm: map_optional_distance_nm(attributes.stroke_width),
italic: attributes.italic,
bold: attributes.bold,
underlined: attributes.underlined,
mirrored: attributes.mirrored,
multiline: attributes.multiline,
keep_upright: attributes.keep_upright,
size_nm: attributes.size.map(map_vector2_nm),
}
}
fn map_text_spec_from_proto(text: common_types::Text) -> TextSpec {
TextSpec {
text: text.text,
position_nm: text.position.map(map_vector2_nm),
attributes: text.attributes.map(map_text_attributes_spec_from_proto),
hyperlink: if text.hyperlink.is_empty() {
None
} else {
Some(text.hyperlink)
},
}
}
fn map_text_box_spec_from_proto(text: common_types::TextBox) -> TextBoxSpec {
TextBoxSpec {
text: text.text,
top_left_nm: text.top_left.map(map_vector2_nm),
bottom_right_nm: text.bottom_right.map(map_vector2_nm),
attributes: text.attributes.map(map_text_attributes_spec_from_proto),
}
}
fn map_text_object_spec_from_proto(text: common_commands::TextOrTextBox) -> Option<TextObjectSpec> {
match text.inner {
Some(common_commands::text_or_text_box::Inner::Text(value)) => {
Some(TextObjectSpec::Text(map_text_spec_from_proto(value)))
}
Some(common_commands::text_or_text_box::Inner::Textbox(value)) => {
Some(TextObjectSpec::TextBox(map_text_box_spec_from_proto(value)))
}
None => None,
}
}
fn map_text_shape_geometry(
shape: common_types::GraphicShape,
) -> Result<TextShapeGeometry, KiCadError> {
match shape.geometry {
Some(common_types::graphic_shape::Geometry::Segment(segment)) => {
Ok(TextShapeGeometry::Segment {
start_nm: segment.start.map(map_vector2_nm),
end_nm: segment.end.map(map_vector2_nm),
})
}
Some(common_types::graphic_shape::Geometry::Rectangle(rectangle)) => {
Ok(TextShapeGeometry::Rectangle {
top_left_nm: rectangle.top_left.map(map_vector2_nm),
bottom_right_nm: rectangle.bottom_right.map(map_vector2_nm),
corner_radius_nm: map_optional_distance_nm(rectangle.corner_radius),
})
}
Some(common_types::graphic_shape::Geometry::Arc(arc)) => Ok(TextShapeGeometry::Arc {
start_nm: arc.start.map(map_vector2_nm),
mid_nm: arc.mid.map(map_vector2_nm),
end_nm: arc.end.map(map_vector2_nm),
}),
Some(common_types::graphic_shape::Geometry::Circle(circle)) => {
Ok(TextShapeGeometry::Circle {
center_nm: circle.center.map(map_vector2_nm),
radius_point_nm: circle.radius_point.map(map_vector2_nm),
})
}
Some(common_types::graphic_shape::Geometry::Polygon(polygon)) => {
let polygons = polygon
.polygons
.into_iter()
.map(map_polygon_with_holes)
.collect::<Result<Vec<_>, _>>()?;
Ok(TextShapeGeometry::Polygon { polygons })
}
Some(common_types::graphic_shape::Geometry::Bezier(bezier)) => {
Ok(TextShapeGeometry::Bezier {
start_nm: bezier.start.map(map_vector2_nm),
control1_nm: bezier.control1.map(map_vector2_nm),
control2_nm: bezier.control2.map(map_vector2_nm),
end_nm: bezier.end.map(map_vector2_nm),
})
}
None => Ok(TextShapeGeometry::Unknown),
}
}
fn map_text_shape(shape: common_types::GraphicShape) -> Result<TextShape, KiCadError> {
let geometry = map_text_shape_geometry(shape.clone())?;
let attributes = shape.attributes.unwrap_or_default();
let stroke = attributes.stroke;
let fill = attributes.fill;
Ok(TextShape {
geometry,
stroke_width_nm: stroke
.clone()
.and_then(|value| map_optional_distance_nm(value.width)),
stroke_style: stroke.as_ref().map(|value| value.style),
stroke_color: stroke.and_then(|value| map_optional_color(value.color)),
fill_type: fill.as_ref().map(|value| value.fill_type),
fill_color: fill.and_then(|value| map_optional_color(value.color)),
})
}
fn map_text_with_shapes(
row: common_commands::TextWithShapes,
) -> Result<TextAsShapesEntry, KiCadError> {
let source = row.text.and_then(map_text_object_spec_from_proto);
let shapes = row
.shapes
.unwrap_or_default()
.shapes
.into_iter()
.map(map_text_shape)
.collect::<Result<Vec<_>, _>>()?;
Ok(TextAsShapesEntry { source, shapes })
}
fn layer_to_model(layer_id: i32) -> BoardLayerInfo {
let name = board_types::BoardLayer::try_from(layer_id)
.map(|layer| layer.as_str_name().to_string())
.unwrap_or_else(|_| format!("UNKNOWN_LAYER({layer_id})"));
BoardLayerInfo { id: layer_id, name }
}
fn board_origin_kind_to_proto(kind: BoardOriginKind) -> i32 {
match kind {
BoardOriginKind::Grid => board_commands::BoardOriginType::BotGrid as i32,
BoardOriginKind::Drill => board_commands::BoardOriginType::BotDrill as i32,
}
}
fn summarize_selection(items: Vec<prost_types::Any>) -> SelectionSummary {
let mut counts = BTreeMap::<String, usize>::new();
for item in &items {
let entry = counts.entry(item.type_url.clone()).or_insert(0);
*entry += 1;
}
SelectionSummary {
total_items: items.len(),
type_url_counts: counts
.into_iter()
.map(|(type_url, count)| SelectionTypeCount { type_url, count })
.collect(),
}
}
fn summarize_item_details(
items: Vec<prost_types::Any>,
) -> Result<Vec<SelectionItemDetail>, 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<common_types::Kiid>,
boxes: Vec<common_types::Box2>,
) -> Result<Vec<ItemBoundingBox>, 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 map_polygon_with_holes(
polygon: common_types::PolygonWithHoles,
) -> Result<PolygonWithHolesNm, KiCadError> {
Ok(PolygonWithHolesNm {
outline: polygon.outline.map(map_polyline).transpose()?,
holes: polygon
.holes
.into_iter()
.map(map_polyline)
.collect::<Result<Vec<_>, _>>()?,
})
}
fn map_polyline(line: common_types::PolyLine) -> Result<PolyLineNm, KiCadError> {
Ok(PolyLineNm {
nodes: line
.nodes
.into_iter()
.map(map_polyline_node)
.collect::<Result<Vec<_>, _>>()?,
closed: line.closed,
})
}
fn map_polyline_node(
node: common_types::PolyLineNode,
) -> Result<PolyLineNodeGeometryNm, KiCadError> {
match node.geometry {
Some(common_types::poly_line_node::Geometry::Point(point)) => {
Ok(PolyLineNodeGeometryNm::Point(map_vector2_nm(point)))
}
Some(common_types::poly_line_node::Geometry::Arc(arc)) => {
let start = arc.start.ok_or_else(|| KiCadError::InvalidResponse {
reason: "polyline arc node missing start point".to_string(),
})?;
let mid = arc.mid.ok_or_else(|| KiCadError::InvalidResponse {
reason: "polyline arc node missing mid point".to_string(),
})?;
let end = arc.end.ok_or_else(|| KiCadError::InvalidResponse {
reason: "polyline arc node missing end point".to_string(),
})?;
Ok(PolyLineNodeGeometryNm::Arc(ArcStartMidEndNm {
start: map_vector2_nm(start),
mid: map_vector2_nm(mid),
end: map_vector2_nm(end),
}))
}
None => Err(KiCadError::InvalidResponse {
reason: "polyline node has no geometry".to_string(),
}),
}
}
fn map_vector2_nm(value: common_types::Vector2) -> Vector2Nm {
Vector2Nm {
x_nm: value.x_nm,
y_nm: value.y_nm,
}
}
fn vector2_nm_to_proto(value: Vector2Nm) -> common_types::Vector2 {
common_types::Vector2 {
x_nm: value.x_nm,
y_nm: value.y_nm,
}
}
fn decode_any<T: prost::Message + Default>(
payload: &prost_types::Any,
expected_type_name: &str,
) -> Result<T, KiCadError> {
let expected_type_url = envelope::type_url(expected_type_name);
if payload.type_url != expected_type_url {
return Err(KiCadError::UnexpectedPayloadType {
expected_type_url,
actual_type_url: payload.type_url.clone(),
});
}
T::decode(payload.value.as_slice()).map_err(|err| KiCadError::ProtobufDecode(err.to_string()))
}
fn response_payload_as_any(
response: crate::proto::kiapi::common::ApiResponse,
expected_type_name: &str,
) -> Result<prost_types::Any, KiCadError> {
let payload = response.message.ok_or_else(|| KiCadError::MissingPayload {
expected_type_url: envelope::type_url(expected_type_name),
})?;
let expected_type_url = envelope::type_url(expected_type_name);
if payload.type_url != expected_type_url {
return Err(KiCadError::UnexpectedPayloadType {
expected_type_url,
actual_type_url: payload.type_url,
});
}
Ok(payload)
}
fn map_optional_distance_nm(distance: Option<common_types::Distance>) -> Option<i64> {
distance.map(|value| value.value_nm)
}
fn map_optional_color(color: Option<common_types::Color>) -> Option<ColorRgba> {
color.map(|value| ColorRgba {
r: value.r,
g: value.g,
b: value.b,
a: value.a,
})
}
fn map_optional_net(net: Option<board_types::Net>) -> Option<BoardNet> {
net.map(|value| BoardNet {
code: value.code.map_or(0, |code| code.value),
name: value.name,
})
}
fn map_padstack_presence(value: i32) -> PadstackPresenceState {
match board_commands::PadstackPresence::try_from(value) {
Ok(board_commands::PadstackPresence::PspPresent) => PadstackPresenceState::Present,
Ok(board_commands::PadstackPresence::PspNotPresent) => PadstackPresenceState::NotPresent,
_ => PadstackPresenceState::Unknown(value),
}
}
fn map_board_stackup_layer_type(value: i32) -> BoardStackupLayerType {
match board_proto::BoardStackupLayerType::try_from(value) {
Ok(board_proto::BoardStackupLayerType::BsltCopper) => BoardStackupLayerType::Copper,
Ok(board_proto::BoardStackupLayerType::BsltDielectric) => BoardStackupLayerType::Dielectric,
Ok(board_proto::BoardStackupLayerType::BsltSilkscreen) => BoardStackupLayerType::Silkscreen,
Ok(board_proto::BoardStackupLayerType::BsltSoldermask) => BoardStackupLayerType::SolderMask,
Ok(board_proto::BoardStackupLayerType::BsltSolderpaste) => {
BoardStackupLayerType::SolderPaste
}
Ok(board_proto::BoardStackupLayerType::BsltUndefined) => BoardStackupLayerType::Undefined,
_ => BoardStackupLayerType::Unknown(value),
}
}
fn map_board_layer_class(value: i32) -> BoardLayerClass {
match board_proto::BoardLayerClass::try_from(value) {
Ok(board_proto::BoardLayerClass::BlcSilkscreen) => BoardLayerClass::Silkscreen,
Ok(board_proto::BoardLayerClass::BlcCopper) => BoardLayerClass::Copper,
Ok(board_proto::BoardLayerClass::BlcEdges) => BoardLayerClass::Edges,
Ok(board_proto::BoardLayerClass::BlcCourtyard) => BoardLayerClass::Courtyard,
Ok(board_proto::BoardLayerClass::BlcFabrication) => BoardLayerClass::Fabrication,
Ok(board_proto::BoardLayerClass::BlcOther) => BoardLayerClass::Other,
_ => BoardLayerClass::Unknown(value),
}
}
fn map_inactive_layer_display_mode(value: i32) -> InactiveLayerDisplayMode {
match board_commands::InactiveLayerDisplayMode::try_from(value) {
Ok(board_commands::InactiveLayerDisplayMode::IldmNormal) => {
InactiveLayerDisplayMode::Normal
}
Ok(board_commands::InactiveLayerDisplayMode::IldmDimmed) => {
InactiveLayerDisplayMode::Dimmed
}
Ok(board_commands::InactiveLayerDisplayMode::IldmHidden) => {
InactiveLayerDisplayMode::Hidden
}
_ => InactiveLayerDisplayMode::Unknown(value),
}
}
fn map_net_color_display_mode(value: i32) -> NetColorDisplayMode {
match board_commands::NetColorDisplayMode::try_from(value) {
Ok(board_commands::NetColorDisplayMode::NcdmAll) => NetColorDisplayMode::All,
Ok(board_commands::NetColorDisplayMode::NcdmRatsnest) => NetColorDisplayMode::Ratsnest,
Ok(board_commands::NetColorDisplayMode::NcdmOff) => NetColorDisplayMode::Off,
_ => NetColorDisplayMode::Unknown(value),
}
}
fn map_board_flip_mode(value: i32) -> BoardFlipMode {
match board_commands::BoardFlipMode::try_from(value) {
Ok(board_commands::BoardFlipMode::BfmNormal) => BoardFlipMode::Normal,
Ok(board_commands::BoardFlipMode::BfmFlippedX) => BoardFlipMode::FlippedX,
_ => BoardFlipMode::Unknown(value),
}
}
fn map_ratsnest_display_mode(value: i32) -> RatsnestDisplayMode {
match board_commands::RatsnestDisplayMode::try_from(value) {
Ok(board_commands::RatsnestDisplayMode::RdmAllLayers) => RatsnestDisplayMode::AllLayers,
Ok(board_commands::RatsnestDisplayMode::RdmVisibleLayers) => {
RatsnestDisplayMode::VisibleLayers
}
_ => RatsnestDisplayMode::Unknown(value),
}
}
fn map_board_stackup(stackup: board_proto::BoardStackup) -> BoardStackup {
let finish_type_name = stackup
.finish
.map(|finish| finish.type_name)
.unwrap_or_default();
let impedance_controlled = stackup
.impedance
.map(|impedance| impedance.is_controlled)
.unwrap_or(false);
let edge = stackup.edge.unwrap_or_default();
let edge_has_castellated_pads = edge
.castellation
.map(|value| value.has_castellated_pads)
.unwrap_or(false);
let edge_has_edge_plating = edge
.plating
.map(|value| value.has_edge_plating)
.unwrap_or(false);
let layers = stackup
.layers
.into_iter()
.map(|layer| BoardStackupLayer {
layer: layer_to_model(layer.layer),
user_name: layer.user_name,
material_name: layer.material_name,
enabled: layer.enabled,
thickness_nm: map_optional_distance_nm(layer.thickness),
layer_type: map_board_stackup_layer_type(layer.r#type),
color: map_optional_color(layer.color),
dielectric_layers: layer
.dielectric
.unwrap_or_default()
.layer
.into_iter()
.map(|dielectric| BoardStackupDielectricProperties {
epsilon_r: dielectric.epsilon_r,
loss_tangent: dielectric.loss_tangent,
material_name: dielectric.material_name,
thickness_nm: map_optional_distance_nm(dielectric.thickness),
})
.collect(),
})
.collect();
BoardStackup {
finish_type_name,
impedance_controlled,
edge_has_castellated_pads,
edge_has_edge_plating,
layers,
}
}
fn map_graphics_defaults(defaults: board_proto::GraphicsDefaults) -> GraphicsDefaults {
GraphicsDefaults {
layers: defaults
.layers
.into_iter()
.map(|layer| {
let text = layer.text.unwrap_or_default();
let text_font_name = if text.font_name.is_empty() {
None
} else {
Some(text.font_name)
};
BoardLayerGraphicsDefault {
layer_class: map_board_layer_class(layer.layer),
line_thickness_nm: map_optional_distance_nm(layer.line_thickness),
text_font_name,
text_size_nm: text.size.map(map_vector2_nm),
text_stroke_width_nm: map_optional_distance_nm(text.stroke_width),
}
})
.collect(),
}
}
fn map_board_editor_appearance_settings(
settings: board_commands::BoardEditorAppearanceSettings,
) -> BoardEditorAppearanceSettings {
BoardEditorAppearanceSettings {
inactive_layer_display: map_inactive_layer_display_mode(settings.inactive_layer_display),
net_color_display: map_net_color_display_mode(settings.net_color_display),
board_flip: map_board_flip_mode(settings.board_flip),
ratsnest_display: map_ratsnest_display_mode(settings.ratsnest_display),
}
}
fn map_net_class_type(value: i32) -> NetClassType {
match common_project::NetClassType::try_from(value) {
Ok(common_project::NetClassType::NctExplicit) => NetClassType::Explicit,
Ok(common_project::NetClassType::NctImplicit) => NetClassType::Implicit,
_ => NetClassType::Unknown(value),
}
}
fn map_net_class_info(net_class: common_project::NetClass) -> NetClassInfo {
let board = net_class.board.map(|board| NetClassBoardSettings {
clearance_nm: map_optional_distance_nm(board.clearance),
track_width_nm: map_optional_distance_nm(board.track_width),
diff_pair_track_width_nm: map_optional_distance_nm(board.diff_pair_track_width),
diff_pair_gap_nm: map_optional_distance_nm(board.diff_pair_gap),
diff_pair_via_gap_nm: map_optional_distance_nm(board.diff_pair_via_gap),
color: map_optional_color(board.color),
tuning_profile: board.tuning_profile.filter(|value| !value.is_empty()),
has_via_stack: board.via_stack.is_some(),
has_microvia_stack: board.microvia_stack.is_some(),
});
NetClassInfo {
name: net_class.name,
priority: net_class.priority,
class_type: map_net_class_type(net_class.r#type),
constituents: net_class.constituents,
board,
}
}
fn map_netclass_for_nets_response(
response: board_commands::NetClassForNetsResponse,
) -> Vec<NetClassForNetEntry> {
let mut rows: Vec<(String, common_project::NetClass)> = response.classes.into_iter().collect();
rows.sort_by(|left, right| left.0.cmp(&right.0));
rows.into_iter()
.map(|(net_name, net_class)| NetClassForNetEntry {
net_name,
net_class: map_net_class_info(net_class),
})
.collect()
}
fn map_via_type(value: i32) -> PcbViaType {
match board_types::ViaType::try_from(value) {
Ok(board_types::ViaType::VtThrough) => PcbViaType::Through,
Ok(board_types::ViaType::VtBlindBuried) => PcbViaType::BlindBuried,
Ok(board_types::ViaType::VtMicro) => PcbViaType::Micro,
Ok(board_types::ViaType::VtBlind) => PcbViaType::Blind,
Ok(board_types::ViaType::VtBuried) => PcbViaType::Buried,
_ => PcbViaType::Unknown(value),
}
}
fn map_pad_type(value: i32) -> PcbPadType {
match board_types::PadType::try_from(value) {
Ok(board_types::PadType::PtPth) => PcbPadType::Pth,
Ok(board_types::PadType::PtSmd) => PcbPadType::Smd,
Ok(board_types::PadType::PtEdgeConnector) => PcbPadType::EdgeConnector,
Ok(board_types::PadType::PtNpth) => PcbPadType::Npth,
_ => PcbPadType::Unknown(value),
}
}
fn map_zone_type(value: i32) -> PcbZoneType {
match board_types::ZoneType::try_from(value) {
Ok(board_types::ZoneType::ZtCopper) => PcbZoneType::Copper,
Ok(board_types::ZoneType::ZtGraphical) => PcbZoneType::Graphical,
Ok(board_types::ZoneType::ZtRuleArea) => PcbZoneType::RuleArea,
Ok(board_types::ZoneType::ZtTeardrop) => PcbZoneType::Teardrop,
_ => PcbZoneType::Unknown(value),
}
}
fn decode_pcb_items(items: Vec<prost_types::Any>) -> Result<Vec<PcbItem>, KiCadError> {
items.into_iter().map(decode_pcb_item).collect()
}
fn decode_pcb_item(item: prost_types::Any) -> Result<PcbItem, KiCadError> {
if item.type_url == envelope::type_url("kiapi.board.types.Track") {
let track = decode_any::<board_types::Track>(&item, "kiapi.board.types.Track")?;
return Ok(PcbItem::Track(PcbTrack {
id: track.id.map(|id| id.value),
start_nm: track.start.map(map_vector2_nm),
end_nm: track.end.map(map_vector2_nm),
width_nm: map_optional_distance_nm(track.width),
layer: layer_to_model(track.layer),
net: map_optional_net(track.net),
}));
}
if item.type_url == envelope::type_url("kiapi.board.types.Arc") {
let arc = decode_any::<board_types::Arc>(&item, "kiapi.board.types.Arc")?;
return Ok(PcbItem::Arc(PcbArc {
id: arc.id.map(|id| id.value),
start_nm: arc.start.map(map_vector2_nm),
mid_nm: arc.mid.map(map_vector2_nm),
end_nm: arc.end.map(map_vector2_nm),
width_nm: map_optional_distance_nm(arc.width),
layer: layer_to_model(arc.layer),
net: map_optional_net(arc.net),
}));
}
if item.type_url == envelope::type_url("kiapi.board.types.Via") {
let via = decode_any::<board_types::Via>(&item, "kiapi.board.types.Via")?;
return Ok(PcbItem::Via(PcbVia {
id: via.id.map(|id| id.value),
position_nm: via.position.map(map_vector2_nm),
via_type: map_via_type(via.r#type),
net: map_optional_net(via.net),
}));
}
if item.type_url == envelope::type_url("kiapi.board.types.FootprintInstance") {
let footprint = decode_any::<board_types::FootprintInstance>(
&item,
"kiapi.board.types.FootprintInstance",
)?;
let reference = footprint
.reference_field
.as_ref()
.and_then(|field| field.text.as_ref())
.and_then(|board_text| board_text.text.as_ref())
.map(|text| text.text.clone())
.filter(|value| !value.is_empty());
let pad_count = footprint
.definition
.as_ref()
.map(|definition| {
definition
.items
.iter()
.filter(|entry| entry.type_url == envelope::type_url("kiapi.board.types.Pad"))
.count()
})
.unwrap_or(0);
return Ok(PcbItem::Footprint(PcbFootprint {
id: footprint.id.map(|id| id.value),
reference,
position_nm: footprint.position.map(map_vector2_nm),
orientation_deg: footprint.orientation.map(|angle| angle.value_degrees),
layer: layer_to_model(footprint.layer),
pad_count,
}));
}
if item.type_url == envelope::type_url("kiapi.board.types.Pad") {
let pad = decode_any::<board_types::Pad>(&item, "kiapi.board.types.Pad")?;
return Ok(PcbItem::Pad(PcbPad {
id: pad.id.map(|id| id.value),
number: pad.number,
pad_type: map_pad_type(pad.r#type),
position_nm: pad.position.map(map_vector2_nm),
net: map_optional_net(pad.net),
}));
}
if item.type_url == envelope::type_url("kiapi.board.types.BoardGraphicShape") {
let shape = decode_any::<board_types::BoardGraphicShape>(
&item,
"kiapi.board.types.BoardGraphicShape",
)?;
let geometry_kind = shape
.shape
.as_ref()
.and_then(|graphic| graphic.geometry.as_ref())
.map(|value| format!("{value:?}"));
return Ok(PcbItem::BoardGraphicShape(PcbBoardGraphicShape {
id: shape.id.map(|id| id.value),
layer: layer_to_model(shape.layer),
net: map_optional_net(shape.net),
geometry_kind,
}));
}
if item.type_url == envelope::type_url("kiapi.board.types.BoardText") {
let text = decode_any::<board_types::BoardText>(&item, "kiapi.board.types.BoardText")?;
return Ok(PcbItem::BoardText(PcbBoardText {
id: text.id.map(|id| id.value),
layer: layer_to_model(text.layer),
text: text.text.map(|value| value.text),
}));
}
if item.type_url == envelope::type_url("kiapi.board.types.BoardTextBox") {
let textbox =
decode_any::<board_types::BoardTextBox>(&item, "kiapi.board.types.BoardTextBox")?;
return Ok(PcbItem::BoardTextBox(PcbBoardTextBox {
id: textbox.id.map(|id| id.value),
layer: layer_to_model(textbox.layer),
text: textbox.textbox.map(|value| value.text),
}));
}
if item.type_url == envelope::type_url("kiapi.board.types.Field") {
let field = decode_any::<board_types::Field>(&item, "kiapi.board.types.Field")?;
let text = field
.text
.and_then(|board_text| board_text.text)
.map(|value| value.text);
return Ok(PcbItem::Field(PcbField {
name: field.name,
visible: field.visible,
text,
}));
}
if item.type_url == envelope::type_url("kiapi.board.types.Zone") {
let zone = decode_any::<board_types::Zone>(&item, "kiapi.board.types.Zone")?;
return Ok(PcbItem::Zone(PcbZone {
id: zone.id.map(|id| id.value),
name: zone.name,
zone_type: map_zone_type(zone.r#type),
layer_count: zone.layers.len(),
filled: zone.filled,
polygon_count: zone.filled_polygons.len(),
}));
}
if item.type_url == envelope::type_url("kiapi.board.types.Dimension") {
let dimension = decode_any::<board_types::Dimension>(&item, "kiapi.board.types.Dimension")?;
return Ok(PcbItem::Dimension(PcbDimension {
id: dimension.id.map(|id| id.value),
layer: layer_to_model(dimension.layer),
text: dimension.text.map(|value| value.text),
style_kind: dimension.dimension_style.map(|value| format!("{value:?}")),
}));
}
if item.type_url == envelope::type_url("kiapi.board.types.Group") {
let group = decode_any::<board_types::Group>(&item, "kiapi.board.types.Group")?;
return Ok(PcbItem::Group(PcbGroup {
id: group.id.map(|id| id.value),
name: group.name,
item_count: group.items.len(),
}));
}
Ok(PcbItem::Unknown(PcbUnknownItem {
type_url: item.type_url,
raw_len: item.value.len(),
}))
}
fn pad_netlist_from_footprint_items(
footprint_items: Vec<prost_types::Any>,
) -> Result<Vec<PadNetEntry>, KiCadError> {
let mut entries = Vec::new();
for item in footprint_items {
if item.type_url != envelope::type_url("kiapi.board.types.FootprintInstance") {
continue;
}
let footprint = decode_any::<board_types::FootprintInstance>(
&item,
"kiapi.board.types.FootprintInstance",
)?;
let footprint_reference = footprint
.reference_field
.as_ref()
.and_then(|field| field.text.as_ref())
.and_then(|board_text| board_text.text.as_ref())
.map(|text| text.text.clone())
.filter(|value| !value.is_empty());
let footprint_id = footprint.id.as_ref().map(|id| id.value.clone());
let footprint_definition = footprint.definition.unwrap_or_default();
for sub_item in footprint_definition.items {
if sub_item.type_url != envelope::type_url("kiapi.board.types.Pad") {
continue;
}
let pad = decode_any::<board_types::Pad>(&sub_item, "kiapi.board.types.Pad")?;
let (net_code, net_name) = match pad.net {
Some(net) => {
let code = net.code.map(|code| code.value);
let name = if net.name.is_empty() {
None
} else {
Some(net.name)
};
(code, name)
}
None => (None, None),
};
entries.push(PadNetEntry {
footprint_reference: footprint_reference.clone(),
footprint_id: footprint_id.clone(),
pad_id: pad.id.map(|id| id.value),
pad_number: pad.number,
net_code,
net_name,
});
}
}
Ok(entries)
}
fn selection_item_detail(item: &prost_types::Any) -> Result<String, KiCadError> {
if item.type_url == envelope::type_url("kiapi.board.types.Track") {
let track = decode_any::<board_types::Track>(item, "kiapi.board.types.Track")?;
return Ok(format_track_selection_detail(track));
}
if item.type_url == envelope::type_url("kiapi.board.types.Arc") {
let arc = decode_any::<board_types::Arc>(item, "kiapi.board.types.Arc")?;
return Ok(format_arc_selection_detail(arc));
}
if item.type_url == envelope::type_url("kiapi.board.types.Via") {
let via = decode_any::<board_types::Via>(item, "kiapi.board.types.Via")?;
return Ok(format_via_selection_detail(via));
}
if item.type_url == envelope::type_url("kiapi.board.types.FootprintInstance") {
let footprint = decode_any::<board_types::FootprintInstance>(
item,
"kiapi.board.types.FootprintInstance",
)?;
return Ok(format_footprint_selection_detail(footprint));
}
if item.type_url == envelope::type_url("kiapi.board.types.Field") {
let field = decode_any::<board_types::Field>(item, "kiapi.board.types.Field")?;
return Ok(format_field_selection_detail(field));
}
if item.type_url == envelope::type_url("kiapi.board.types.BoardText") {
let text = decode_any::<board_types::BoardText>(item, "kiapi.board.types.BoardText")?;
return Ok(format_board_text_selection_detail(text));
}
if item.type_url == envelope::type_url("kiapi.board.types.BoardTextBox") {
let textbox =
decode_any::<board_types::BoardTextBox>(item, "kiapi.board.types.BoardTextBox")?;
return Ok(format_board_textbox_selection_detail(textbox));
}
if item.type_url == envelope::type_url("kiapi.board.types.Pad") {
let pad = decode_any::<board_types::Pad>(item, "kiapi.board.types.Pad")?;
return Ok(format_pad_selection_detail(pad));
}
if item.type_url == envelope::type_url("kiapi.board.types.BoardGraphicShape") {
let shape = decode_any::<board_types::BoardGraphicShape>(
item,
"kiapi.board.types.BoardGraphicShape",
)?;
return Ok(format_board_graphic_shape_selection_detail(shape));
}
if item.type_url == envelope::type_url("kiapi.board.types.Zone") {
let zone = decode_any::<board_types::Zone>(item, "kiapi.board.types.Zone")?;
return Ok(format_zone_selection_detail(zone));
}
if item.type_url == envelope::type_url("kiapi.board.types.Dimension") {
let dimension = decode_any::<board_types::Dimension>(item, "kiapi.board.types.Dimension")?;
return Ok(format_dimension_selection_detail(dimension));
}
if item.type_url == envelope::type_url("kiapi.board.types.Group") {
let group = decode_any::<board_types::Group>(item, "kiapi.board.types.Group")?;
return Ok(format_group_selection_detail(group));
}
Ok(format!("unparsed payload ({} bytes)", item.value.len()))
}
fn format_track_selection_detail(track: board_types::Track) -> String {
let id = track.id.map_or_else(|| "-".to_string(), |id| id.value);
let start = track
.start
.map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm));
let end = track
.end
.map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm));
let width = track
.width
.map_or_else(|| "-".to_string(), |w| w.value_nm.to_string());
let layer = layer_to_model(track.layer).name;
let net = track
.net
.map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name))
.unwrap_or_else(|| "-".to_string());
format!("track id={id} start_nm={start} end_nm={end} width_nm={width} layer={layer} net={net}")
}
fn format_arc_selection_detail(arc: board_types::Arc) -> String {
let id = arc.id.map_or_else(|| "-".to_string(), |id| id.value);
let start = arc
.start
.map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm));
let mid = arc
.mid
.map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm));
let end = arc
.end
.map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm));
let width = arc
.width
.map_or_else(|| "-".to_string(), |w| w.value_nm.to_string());
let layer = layer_to_model(arc.layer).name;
let net = arc
.net
.map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name))
.unwrap_or_else(|| "-".to_string());
format!(
"arc id={id} start_nm={start} mid_nm={mid} end_nm={end} width_nm={width} layer={layer} net={net}"
)
}
fn format_via_selection_detail(via: board_types::Via) -> String {
let id = via.id.map_or_else(|| "-".to_string(), |id| id.value);
let position = via
.position
.map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm));
let net = via
.net
.map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name))
.unwrap_or_else(|| "-".to_string());
let via_type = board_types::ViaType::try_from(via.r#type)
.map(|value| value.as_str_name().to_string())
.unwrap_or_else(|_| format!("UNKNOWN({})", via.r#type));
format!("via id={id} pos_nm={position} type={via_type} net={net}")
}
fn format_footprint_selection_detail(footprint: board_types::FootprintInstance) -> String {
let id = footprint.id.map_or_else(|| "-".to_string(), |id| id.value);
let reference = footprint
.reference_field
.as_ref()
.and_then(|field| field.text.as_ref())
.and_then(|board_text| board_text.text.as_ref())
.map(|text| text.text.clone())
.unwrap_or_else(|| "-".to_string());
let position = footprint
.position
.map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm));
let orientation_deg = footprint.orientation.map_or_else(
|| "-".to_string(),
|orientation| orientation.value_degrees.to_string(),
);
let layer = layer_to_model(footprint.layer).name;
let pad_count = footprint
.definition
.as_ref()
.map(|definition| {
definition
.items
.iter()
.filter(|entry| entry.type_url == envelope::type_url("kiapi.board.types.Pad"))
.count()
})
.unwrap_or(0);
format!(
"footprint id={id} ref={reference} pos_nm={position} orientation_deg={orientation_deg} layer={layer} pad_count={pad_count}"
)
}
fn format_field_selection_detail(field: board_types::Field) -> String {
let text = field
.text
.as_ref()
.and_then(|board_text| board_text.text.as_ref())
.map(|text| text.text.clone())
.unwrap_or_else(|| "-".to_string());
format!(
"field name={} visible={} text={}",
field.name, field.visible, text
)
}
fn format_board_text_selection_detail(text: board_types::BoardText) -> String {
let id = text.id.map_or_else(|| "-".to_string(), |id| id.value);
let layer = layer_to_model(text.layer).name;
let body = text
.text
.as_ref()
.map(|value| value.text.clone())
.unwrap_or_else(|| "-".to_string());
format!("text id={id} layer={layer} text={body}")
}
fn format_board_textbox_selection_detail(textbox: board_types::BoardTextBox) -> String {
let id = textbox.id.map_or_else(|| "-".to_string(), |id| id.value);
let layer = layer_to_model(textbox.layer).name;
let body = textbox
.textbox
.as_ref()
.map(|value| value.text.clone())
.unwrap_or_else(|| "-".to_string());
format!("textbox id={id} layer={layer} text={body}")
}
fn format_pad_selection_detail(pad: board_types::Pad) -> String {
let id = pad.id.map_or_else(|| "-".to_string(), |id| id.value);
let pad_type = board_types::PadType::try_from(pad.r#type)
.map(|value| value.as_str_name().to_string())
.unwrap_or_else(|_| format!("UNKNOWN({})", pad.r#type));
let position = pad
.position
.map_or_else(|| "-".to_string(), |v| format!("{},{}", v.x_nm, v.y_nm));
let net = pad
.net
.map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name))
.unwrap_or_else(|| "-".to_string());
format!(
"pad id={id} number={} type={pad_type} pos_nm={position} net={net}",
pad.number
)
}
fn format_board_graphic_shape_selection_detail(shape: board_types::BoardGraphicShape) -> String {
let id = shape.id.map_or_else(|| "-".to_string(), |id| id.value);
let layer = layer_to_model(shape.layer).name;
let net = shape
.net
.map(|n| format!("{}:{}", n.code.map_or(0, |c| c.value), n.name))
.unwrap_or_else(|| "-".to_string());
let geometry = shape
.shape
.as_ref()
.map(|graphic| format!("{:?}", graphic.geometry))
.unwrap_or_else(|| "-".to_string());
format!("graphic id={id} layer={layer} net={net} geometry={geometry}")
}
fn format_zone_selection_detail(zone: board_types::Zone) -> String {
let id = zone.id.map_or_else(|| "-".to_string(), |id| id.value);
let zone_type = board_types::ZoneType::try_from(zone.r#type)
.map(|value| value.as_str_name().to_string())
.unwrap_or_else(|_| format!("UNKNOWN({})", zone.r#type));
format!(
"zone id={id} name={} type={} layer_count={} filled={} polygon_count={}",
zone.name,
zone_type,
zone.layers.len(),
zone.filled,
zone.filled_polygons.len()
)
}
fn format_dimension_selection_detail(dimension: board_types::Dimension) -> String {
let id = dimension.id.map_or_else(|| "-".to_string(), |id| id.value);
let layer = layer_to_model(dimension.layer).name;
let text = dimension
.text
.as_ref()
.map(|value| value.text.clone())
.unwrap_or_else(|| "-".to_string());
let style = format!("{:?}", dimension.dimension_style);
format!(
"dimension id={id} layer={layer} text={} style={style}",
text
)
}
fn format_group_selection_detail(group: board_types::Group) -> String {
let id = group.id.map_or_else(|| "-".to_string(), |id| id.value);
format!(
"group id={id} name={} item_count={}",
group.name,
group.items.len()
)
}
fn any_to_pretty_debug(item: &prost_types::Any) -> Result<String, KiCadError> {
macro_rules! debug_any {
($(($url:literal, $ty:ty)),* $(,)?) => {
$(
if item.type_url == envelope::type_url($url) {
let value = decode_any::<$ty>(item, $url)?;
return Ok(format!("{:#?}", value));
}
)*
};
}
debug_any!(
("kiapi.board.types.Track", board_types::Track),
("kiapi.board.types.Arc", board_types::Arc),
("kiapi.board.types.Via", board_types::Via),
(
"kiapi.board.types.FootprintInstance",
board_types::FootprintInstance
),
("kiapi.board.types.Pad", board_types::Pad),
(
"kiapi.board.types.BoardGraphicShape",
board_types::BoardGraphicShape
),
("kiapi.board.types.BoardText", board_types::BoardText),
("kiapi.board.types.BoardTextBox", board_types::BoardTextBox),
("kiapi.board.types.Field", board_types::Field),
("kiapi.board.types.Zone", board_types::Zone),
("kiapi.board.types.Dimension", board_types::Dimension),
("kiapi.board.types.Group", board_types::Group),
);
Ok(format!(
"unparsed_any type_url={} raw_len={}",
item.type_url,
item.value.len()
))
}
fn select_single_board_document(
docs: &[DocumentSpecifier],
) -> Result<&DocumentSpecifier, KiCadError> {
if docs.is_empty() {
return Err(KiCadError::BoardNotOpen);
}
if docs.len() > 1 {
let boards = docs
.iter()
.map(|doc| {
doc.board_filename
.clone()
.unwrap_or_else(|| "<unknown>".to_string())
})
.collect();
return Err(KiCadError::AmbiguousBoardSelection { boards });
}
Ok(&docs[0])
}
fn select_single_project_path(docs: &[DocumentSpecifier]) -> Result<PathBuf, KiCadError> {
let mut paths = BTreeSet::new();
for doc in docs {
if let Some(path) = doc.project.path.as_ref() {
paths.insert(path.display().to_string());
}
}
if paths.is_empty() {
return Err(KiCadError::BoardNotOpen);
}
if paths.len() > 1 {
return Err(KiCadError::AmbiguousProjectPath {
paths: paths.into_iter().collect(),
});
}
let first = paths.into_iter().next().ok_or(KiCadError::BoardNotOpen)?;
Ok(PathBuf::from(first))
}
fn resolve_socket_uri(explicit: Option<&str>) -> String {
if let Some(socket) = explicit {
return normalize_socket_uri(socket);
}
if let Ok(socket) = std::env::var(KICAD_API_SOCKET_ENV) {
if !socket.is_empty() {
return normalize_socket_uri(&socket);
}
}
normalize_socket_uri(default_socket_path().to_string_lossy().as_ref())
}
fn default_socket_path() -> PathBuf {
#[cfg(target_os = "windows")]
{
return std::env::temp_dir().join("kicad").join("api.sock");
}
#[cfg(not(target_os = "windows"))]
{
if let Some(home) = std::env::var_os("HOME") {
let flatpak = PathBuf::from(home)
.join(".var")
.join("app")
.join("org.kicad.KiCad")
.join("cache")
.join("tmp")
.join("kicad")
.join("api.sock");
if flatpak.exists() {
return flatpak;
}
}
PathBuf::from("/tmp/kicad/api.sock")
}
}
fn normalize_socket_uri(socket: &str) -> String {
if socket.contains("://") {
return socket.to_string();
}
format!("ipc://{socket}")
}
fn ipc_path_from_uri(socket_uri: &str) -> Option<PathBuf> {
let raw_path = socket_uri.strip_prefix("ipc://")?;
Some(PathBuf::from(raw_path))
}
fn is_missing_ipc_socket(socket_uri: &str) -> bool {
if let Some(path) = ipc_path_from_uri(socket_uri) {
return !path.exists();
}
false
}
fn default_client_name() -> String {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
format!("kicad-ipc-{}-{millis}", std::process::id())
}
#[cfg(test)]
mod tests {
use super::{
any_to_pretty_debug, ensure_item_request_ok, layer_to_model, map_hit_test_result,
map_item_bounding_boxes, map_polygon_with_holes, 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, text_horizontal_alignment_to_proto, text_spec_to_proto,
PCB_OBJECT_TYPES,
};
use crate::error::KiCadError;
use crate::model::common::{
DocumentSpecifier, DocumentType, ProjectInfo, TextAttributesSpec, TextHorizontalAlignment,
TextSpec,
};
use prost::Message;
use std::path::PathBuf;
#[test]
fn normalize_socket_uri_adds_ipc_scheme() {
let normalized = normalize_socket_uri("/tmp/kicad/api.sock");
assert_eq!(normalized, "ipc:///tmp/kicad/api.sock");
}
#[test]
fn normalize_socket_uri_preserves_existing_scheme() {
let normalized = normalize_socket_uri("ipc:///tmp/kicad/api.sock");
assert_eq!(normalized, "ipc:///tmp/kicad/api.sock");
}
#[test]
fn select_single_project_path_picks_unique_path() {
let docs = vec![DocumentSpecifier {
document_type: DocumentType::Pcb,
board_filename: Some("demo.kicad_pcb".to_string()),
project: ProjectInfo {
name: Some("demo".to_string()),
path: Some(PathBuf::from("/tmp/demo")),
},
}];
let result = select_single_project_path(&docs)
.expect("a single project path should be selected when exactly one path exists");
assert_eq!(result, PathBuf::from("/tmp/demo"));
}
#[test]
fn select_single_project_path_errors_on_ambiguity() {
let docs = vec![
DocumentSpecifier {
document_type: DocumentType::Pcb,
board_filename: Some("a.kicad_pcb".to_string()),
project: ProjectInfo {
name: Some("a".to_string()),
path: Some(PathBuf::from("/tmp/a")),
},
},
DocumentSpecifier {
document_type: DocumentType::Pcb,
board_filename: Some("b.kicad_pcb".to_string()),
project: ProjectInfo {
name: Some("b".to_string()),
path: Some(PathBuf::from("/tmp/b")),
},
},
];
let result = select_single_project_path(&docs);
assert!(matches!(
result,
Err(KiCadError::AmbiguousProjectPath { .. })
));
}
#[test]
fn select_single_project_path_requires_open_board() {
let docs: Vec<DocumentSpecifier> = Vec::new();
let result = select_single_project_path(&docs);
assert!(matches!(result, Err(KiCadError::BoardNotOpen)));
}
#[test]
fn select_single_board_document_errors_on_multiple_open_boards() {
let docs = vec![
DocumentSpecifier {
document_type: DocumentType::Pcb,
board_filename: Some("a.kicad_pcb".to_string()),
project: ProjectInfo {
name: Some("a".to_string()),
path: Some(PathBuf::from("/tmp/a")),
},
},
DocumentSpecifier {
document_type: DocumentType::Pcb,
board_filename: Some("b.kicad_pcb".to_string()),
project: ProjectInfo {
name: Some("b".to_string()),
path: Some(PathBuf::from("/tmp/b")),
},
},
];
let result = select_single_board_document(&docs);
assert!(matches!(
result,
Err(KiCadError::AmbiguousBoardSelection { .. })
));
}
#[test]
fn layer_to_model_formats_unknown_id() {
let layer = layer_to_model(999);
assert_eq!(layer.name, "UNKNOWN_LAYER(999)");
assert_eq!(layer.id, 999);
}
#[test]
fn model_document_to_proto_carries_board_filename_and_project() {
let document = DocumentSpecifier {
document_type: DocumentType::Pcb,
board_filename: Some("demo.kicad_pcb".to_string()),
project: ProjectInfo {
name: Some("demo".to_string()),
path: Some(PathBuf::from("/tmp/demo")),
},
};
let proto = model_document_to_proto(&document);
assert_eq!(
proto.r#type,
crate::model::common::DocumentType::Pcb.to_proto()
);
let identifier = proto.identifier.expect("identifier should be present");
match identifier {
crate::proto::kiapi::common::types::document_specifier::Identifier::BoardFilename(
filename,
) => assert_eq!(filename, "demo.kicad_pcb"),
other => panic!("unexpected identifier variant: {other:?}"),
}
let project = proto.project.expect("project should be present");
assert_eq!(project.name, "demo");
assert_eq!(project.path, "/tmp/demo");
}
#[test]
fn summarize_selection_counts_payload_types() {
let items = vec![
prost_types::Any {
type_url: "type.googleapis.com/kiapi.board.types.Track".to_string(),
value: vec![1, 2, 3],
},
prost_types::Any {
type_url: "type.googleapis.com/kiapi.board.types.Track".to_string(),
value: vec![9],
},
prost_types::Any {
type_url: "type.googleapis.com/kiapi.board.types.Via".to_string(),
value: vec![7, 7],
},
];
let summary = summarize_selection(items);
assert_eq!(summary.total_items, 3);
assert_eq!(summary.type_url_counts.len(), 2);
assert_eq!(summary.type_url_counts[0].count, 2);
assert_eq!(
summary.type_url_counts[0].type_url,
"type.googleapis.com/kiapi.board.types.Track"
);
assert_eq!(summary.type_url_counts[1].count, 1);
assert_eq!(
summary.type_url_counts[1].type_url,
"type.googleapis.com/kiapi.board.types.Via"
);
}
#[test]
fn selection_item_detail_reports_track_fields() {
let track = crate::proto::kiapi::board::types::Track {
id: Some(crate::proto::kiapi::common::types::Kiid {
value: "track-id".to_string(),
}),
start: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 1, y_nm: 2 }),
end: Some(crate::proto::kiapi::common::types::Vector2 { x_nm: 3, y_nm: 4 }),
width: Some(crate::proto::kiapi::common::types::Distance { value_nm: 99 }),
locked: 0,
layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32,
net: Some(crate::proto::kiapi::board::types::Net {
code: Some(crate::proto::kiapi::board::types::NetCode { value: 12 }),
name: "GND".to_string(),
}),
};
let item = prost_types::Any {
type_url: super::envelope::type_url("kiapi.board.types.Track"),
value: track.encode_to_vec(),
};
let detail = selection_item_detail(&item).expect("track detail should decode");
assert!(detail.contains("track id=track-id"));
assert!(detail.contains("layer=BL_F_Cu"));
assert!(detail.contains("net=12:GND"));
}
#[test]
fn pad_netlist_from_footprint_items_extracts_pad_entries() {
let pad = crate::proto::kiapi::board::types::Pad {
id: Some(crate::proto::kiapi::common::types::Kiid {
value: "pad-id".to_string(),
}),
locked: 0,
number: "1".to_string(),
net: Some(crate::proto::kiapi::board::types::Net {
code: Some(crate::proto::kiapi::board::types::NetCode { value: 5 }),
name: "Net-(P1-PM)".to_string(),
}),
r#type: crate::proto::kiapi::board::types::PadType::PtPth as i32,
pad_stack: None,
position: None,
copper_clearance_override: None,
pad_to_die_length: None,
symbol_pin: None,
pad_to_die_delay: None,
};
let footprint = crate::proto::kiapi::board::types::FootprintInstance {
id: Some(crate::proto::kiapi::common::types::Kiid {
value: "fp-id".to_string(),
}),
position: None,
orientation: None,
layer: crate::proto::kiapi::board::types::BoardLayer::BlFCu as i32,
locked: 0,
definition: Some(crate::proto::kiapi::board::types::Footprint {
id: None,
anchor: None,
attributes: None,
overrides: None,
net_ties: Vec::new(),
private_layers: Vec::new(),
reference_field: None,
value_field: None,
datasheet_field: None,
description_field: None,
items: vec![prost_types::Any {
type_url: super::envelope::type_url("kiapi.board.types.Pad"),
value: pad.encode_to_vec(),
}],
jumpers: None,
}),
reference_field: Some(crate::proto::kiapi::board::types::Field {
id: None,
name: "Reference".to_string(),
text: Some(crate::proto::kiapi::board::types::BoardText {
id: None,
text: Some(crate::proto::kiapi::common::types::Text {
position: None,
attributes: None,
text: "P1".to_string(),
hyperlink: String::new(),
}),
layer: 0,
knockout: false,
locked: 0,
}),
visible: true,
}),
value_field: None,
datasheet_field: None,
description_field: None,
attributes: None,
overrides: None,
symbol_path: None,
symbol_sheet_name: String::new(),
symbol_sheet_filename: String::new(),
symbol_footprint_filters: String::new(),
};
let items = vec![prost_types::Any {
type_url: super::envelope::type_url("kiapi.board.types.FootprintInstance"),
value: footprint.encode_to_vec(),
}];
let netlist = pad_netlist_from_footprint_items(items)
.expect("pad netlist should decode from footprint");
assert_eq!(netlist.len(), 1);
let entry = &netlist[0];
assert_eq!(entry.footprint_reference.as_deref(), Some("P1"));
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
);
}
#[test]
fn text_horizontal_alignment_to_proto_covers_known_variants() {
assert_eq!(
text_horizontal_alignment_to_proto(TextHorizontalAlignment::Left),
crate::proto::kiapi::common::types::HorizontalAlignment::HaLeft as i32
);
assert_eq!(
text_horizontal_alignment_to_proto(TextHorizontalAlignment::Indeterminate),
crate::proto::kiapi::common::types::HorizontalAlignment::HaIndeterminate as i32
);
}
#[test]
fn text_spec_to_proto_maps_optional_fields() {
let spec = TextSpec {
text: "R1".to_string(),
position_nm: Some(crate::model::board::Vector2Nm {
x_nm: 1_000,
y_nm: 2_000,
}),
attributes: Some(TextAttributesSpec {
font_name: Some("KiCad Font".to_string()),
horizontal_alignment: TextHorizontalAlignment::Center,
..TextAttributesSpec::default()
}),
hyperlink: Some("https://example.com".to_string()),
};
let proto = text_spec_to_proto(spec);
assert_eq!(proto.text, "R1");
assert_eq!(proto.hyperlink, "https://example.com");
let position = proto.position.expect("position should be present");
assert_eq!(position.x_nm, 1_000);
assert_eq!(position.y_nm, 2_000);
let attributes = proto.attributes.expect("attributes should be present");
assert_eq!(attributes.font_name, "KiCad Font");
assert_eq!(
attributes.horizontal_alignment,
crate::proto::kiapi::common::types::HorizontalAlignment::HaCenter as i32
);
}
#[test]
fn pcb_object_type_catalog_contains_expected_trace_entry() {
assert!(PCB_OBJECT_TYPES
.iter()
.any(|entry| entry.name == "KOT_PCB_TRACE" && entry.code == 11));
}
#[test]
fn any_to_pretty_debug_handles_unknown_type_without_error() {
let unknown = prost_types::Any {
type_url: "type.googleapis.com/kiapi.board.types.DoesNotExist".to_string(),
value: vec![0xde, 0xad, 0xbe, 0xef],
};
let debug = any_to_pretty_debug(&unknown)
.expect("unknown Any payload type should not fail debug rendering");
assert!(debug.contains("unparsed_any"));
assert!(debug.contains("raw_len=4"));
}
#[test]
fn map_polygon_with_holes_maps_points_and_arcs() {
let polygon = crate::proto::kiapi::common::types::PolygonWithHoles {
outline: Some(crate::proto::kiapi::common::types::PolyLine {
nodes: vec![
crate::proto::kiapi::common::types::PolyLineNode {
geometry: Some(
crate::proto::kiapi::common::types::poly_line_node::Geometry::Point(
crate::proto::kiapi::common::types::Vector2 { x_nm: 10, y_nm: 20 },
),
),
},
crate::proto::kiapi::common::types::PolyLineNode {
geometry: Some(
crate::proto::kiapi::common::types::poly_line_node::Geometry::Arc(
crate::proto::kiapi::common::types::ArcStartMidEnd {
start: Some(crate::proto::kiapi::common::types::Vector2 {
x_nm: 0,
y_nm: 0,
}),
mid: Some(crate::proto::kiapi::common::types::Vector2 {
x_nm: 5,
y_nm: 5,
}),
end: Some(crate::proto::kiapi::common::types::Vector2 {
x_nm: 10,
y_nm: 0,
}),
},
),
),
},
],
closed: true,
}),
holes: vec![crate::proto::kiapi::common::types::PolyLine {
nodes: vec![crate::proto::kiapi::common::types::PolyLineNode {
geometry: Some(
crate::proto::kiapi::common::types::poly_line_node::Geometry::Point(
crate::proto::kiapi::common::types::Vector2 { x_nm: 1, y_nm: 1 },
),
),
}],
closed: true,
}],
};
let mapped = map_polygon_with_holes(polygon).expect("polygon mapping should succeed");
let outline = mapped.outline.expect("outline should be present");
assert_eq!(outline.nodes.len(), 2);
assert!(outline.closed);
assert_eq!(mapped.holes.len(), 1);
}
#[test]
fn map_polygon_with_holes_rejects_missing_arc_points() {
let polygon = crate::proto::kiapi::common::types::PolygonWithHoles {
outline: Some(crate::proto::kiapi::common::types::PolyLine {
nodes: vec![crate::proto::kiapi::common::types::PolyLineNode {
geometry: Some(
crate::proto::kiapi::common::types::poly_line_node::Geometry::Arc(
crate::proto::kiapi::common::types::ArcStartMidEnd {
start: Some(crate::proto::kiapi::common::types::Vector2 {
x_nm: 0,
y_nm: 0,
}),
mid: None,
end: Some(crate::proto::kiapi::common::types::Vector2 {
x_nm: 10,
y_nm: 0,
}),
},
),
),
}],
closed: false,
}),
holes: Vec::new(),
};
let err = map_polygon_with_holes(polygon).expect_err("missing arc point must fail");
assert!(matches!(err, KiCadError::InvalidResponse { .. }));
}
}