diff --git a/README.md b/README.md index c2d6a83..8e205b0 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,12 @@ Legend: | Section | Proto Commands | Implemented | Coverage | | --- | ---: | ---: | ---: | -| Common (base) | 6 | 3 | 50% | +| Common (base) | 6 | 4 | 67% | | Common editor/document | 23 | 9 | 39% | | Project manager | 5 | 3 | 60% | | Board editor (PCB) | 22 | 13 | 59% | | Schematic editor (dedicated proto commands) | 0 | 0 | n/a | -| **Total** | **56** | **28** | **50%** | +| **Total** | **56** | **29** | **52%** | ### Common (base) @@ -53,7 +53,7 @@ Legend: | `GetVersion` | Implemented | `KiCadClient::get_version` | | `GetKiCadBinaryPath` | Not yet | - | | `GetTextExtents` | Implemented | `KiCadClient::get_text_extents_raw`, `KiCadClient::get_text_extents` | -| `GetTextAsShapes` | Not yet | - | +| `GetTextAsShapes` | Implemented | `KiCadClient::get_text_as_shapes_raw`, `KiCadClient::get_text_as_shapes` | | `GetPluginSettingsPath` | Not yet | - | ### Common editor/document diff --git a/docs/TEST_CLI.md b/docs/TEST_CLI.md index cb1e17e..204131d 100644 --- a/docs/TEST_CLI.md +++ b/docs/TEST_CLI.md @@ -71,6 +71,12 @@ Measure text extents: cargo run --bin kicad-ipc-cli -- text-extents --text "R1" ``` +Convert text to shape primitives: + +```bash +cargo run --bin kicad-ipc-cli -- text-as-shapes --text "R1" --text "C5" +``` + List enabled board layers: ```bash diff --git a/src/client.rs b/src/client.rs index 555d882..e238c32 100644 --- a/src/client.rs +++ b/src/client.rs @@ -19,9 +19,9 @@ use crate::model::board::{ }; use crate::model::common::{ DocumentSpecifier, DocumentType, ItemBoundingBox, ItemHitTestResult, PcbObjectTypeCode, - ProjectInfo, SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAttributesSpec, - TextExtents, TextHorizontalAlignment, TextSpec, TextVerticalAlignment, TitleBlockInfo, - VersionInfo, + 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; @@ -40,6 +40,7 @@ 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"; @@ -71,6 +72,7 @@ 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"; @@ -422,6 +424,34 @@ impl KiCadClient { }) } + pub async fn get_text_as_shapes_raw( + &self, + text: Vec, + ) -> Result { + 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, + ) -> Result, 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 { let docs = self.get_open_documents(DocumentType::Pcb).await?; select_single_project_path(&docs) @@ -1286,6 +1316,191 @@ fn text_vertical_alignment_to_proto(value: TextVerticalAlignment) -> 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 { + 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 { + 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::, _>>()?; + 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 { + 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 { + 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::, _>>()?; + 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()) diff --git a/src/lib.rs b/src/lib.rs index 396c225..3b11e53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub use crate::model::board::{ }; pub use crate::model::common::{ DocumentSpecifier, DocumentType, ItemBoundingBox, ItemHitTestResult, PcbObjectTypeCode, - SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAttributesSpec, TextExtents, - TextHorizontalAlignment, TextSpec, TextVerticalAlignment, TitleBlockInfo, VersionInfo, + SelectionItemDetail, SelectionSummary, SelectionTypeCount, TextAsShapesEntry, + TextAttributesSpec, TextBoxSpec, TextExtents, TextHorizontalAlignment, TextObjectSpec, + TextShape, TextShapeGeometry, TextSpec, TextVerticalAlignment, TitleBlockInfo, VersionInfo, }; diff --git a/src/model/common.rs b/src/model/common.rs index e7e8703..6bfd6c5 100644 --- a/src/model/common.rs +++ b/src/model/common.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use std::str::FromStr; -use crate::model::board::Vector2Nm; +use crate::model::board::{ColorRgba, PolygonWithHolesNm, Vector2Nm}; use crate::proto::kiapi::common::types as common_types; #[derive(Clone, Debug, Eq, PartialEq)] @@ -226,6 +226,68 @@ pub struct TextExtents { pub height_nm: i64, } +#[derive(Clone, Debug, PartialEq)] +pub struct TextBoxSpec { + pub text: String, + pub top_left_nm: Option, + pub bottom_right_nm: Option, + pub attributes: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum TextObjectSpec { + Text(TextSpec), + TextBox(TextBoxSpec), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum TextShapeGeometry { + Segment { + start_nm: Option, + end_nm: Option, + }, + Rectangle { + top_left_nm: Option, + bottom_right_nm: Option, + corner_radius_nm: Option, + }, + Arc { + start_nm: Option, + mid_nm: Option, + end_nm: Option, + }, + Circle { + center_nm: Option, + radius_point_nm: Option, + }, + Polygon { + polygons: Vec, + }, + Bezier { + start_nm: Option, + control1_nm: Option, + control2_nm: Option, + end_nm: Option, + }, + Unknown, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextShape { + pub geometry: TextShapeGeometry, + pub stroke_width_nm: Option, + pub stroke_style: Option, + pub stroke_color: Option, + pub fill_type: Option, + pub fill_color: Option, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TextAsShapesEntry { + pub source: Option, + pub shapes: Vec, +} + impl std::fmt::Display for ItemHitTestResult { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let value = match self { diff --git a/test-scripts/kicad-ipc-cli.rs b/test-scripts/kicad-ipc-cli.rs index bf16fc0..6d20915 100644 --- a/test-scripts/kicad-ipc-cli.rs +++ b/test-scripts/kicad-ipc-cli.rs @@ -7,7 +7,7 @@ use std::time::Duration; use kicad_ipc::{ BoardOriginKind, ClientBuilder, DocumentType, KiCadClient, KiCadError, PadstackPresenceState, - PcbObjectTypeCode, TextSpec, Vector2Nm, + PcbObjectTypeCode, TextObjectSpec, TextShapeGeometry, TextSpec, Vector2Nm, }; const REPORT_MAX_PAD_NET_ROWS: usize = 2_000; @@ -40,6 +40,9 @@ enum Command { TextExtents { text: String, }, + TextAsShapes { + text: Vec, + }, Nets, EnabledLayers, ActiveLayer, @@ -224,6 +227,47 @@ async fn run() -> Result<(), KiCadError> { extents.x_nm, extents.y_nm, extents.width_nm, extents.height_nm ); } + Command::TextAsShapes { text } => { + let entries = client + .get_text_as_shapes( + text.into_iter() + .map(|value| TextObjectSpec::Text(TextSpec::plain(value))) + .collect(), + ) + .await?; + println!("text_with_shapes_count={}", entries.len()); + for (index, entry) in entries.iter().enumerate() { + let mut segment_count = 0; + let mut rectangle_count = 0; + let mut arc_count = 0; + let mut circle_count = 0; + let mut polygon_count = 0; + let mut bezier_count = 0; + let mut unknown_count = 0; + for shape in &entry.shapes { + match shape.geometry { + TextShapeGeometry::Segment { .. } => segment_count += 1, + TextShapeGeometry::Rectangle { .. } => rectangle_count += 1, + TextShapeGeometry::Arc { .. } => arc_count += 1, + TextShapeGeometry::Circle { .. } => circle_count += 1, + TextShapeGeometry::Polygon { .. } => polygon_count += 1, + TextShapeGeometry::Bezier { .. } => bezier_count += 1, + TextShapeGeometry::Unknown => unknown_count += 1, + } + } + println!( + "[{index}] shape_count={} segment={} rectangle={} arc={} circle={} polygon={} bezier={} unknown={}", + entry.shapes.len(), + segment_count, + rectangle_count, + arc_count, + circle_count, + polygon_count, + bezier_count, + unknown_count + ); + } + } Command::Nets => { let nets = client.get_nets().await?; if nets.is_empty() { @@ -669,6 +713,33 @@ fn parse_args() -> Result<(CliConfig, Command), KiCadError> { })?, } } + "text-as-shapes" => { + let mut text = Vec::new(); + let mut i = 1; + while i < args.len() { + match args[i].as_str() { + "--text" => { + let value = args.get(i + 1).ok_or_else(|| KiCadError::Config { + reason: "missing value for text-as-shapes --text".to_string(), + })?; + text.push(value.clone()); + i += 2; + } + _ => { + i += 1; + } + } + } + + if text.is_empty() { + return Err(KiCadError::Config { + reason: "text-as-shapes requires one or more `--text ` arguments" + .to_string(), + }); + } + + Command::TextAsShapes { text } + } "nets" => Command::Nets, "enabled-layers" => Command::EnabledLayers, "active-layer" => Command::ActiveLayer, @@ -1004,7 +1075,7 @@ 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 net-classes List project netclass definitions\n text-variables List text variables for current board document\n expand-text-variables Expand variables in provided text values\n Options: --text (repeatable)\n text-extents Measure text bounding box\n Options: --text \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 types-pcb List PCB KiCad object type IDs from proto enum\n items-raw --type-id ... Dump raw Any payloads for requested item type IDs\n items-raw-all-pcb [--debug] Dump all PCB item payloads across all PCB object types\n pad-shape-polygon --pad-id ... --layer-id [--debug]\n Dump pad polygons on a target layer\n padstack-presence --item-id ... --layer-id ... [--debug]\n Check padstack shape presence matrix across layers\n title-block Show title block fields\n board-as-string Dump board as KiCad s-expression text\n selection-as-string Dump current selection as KiCad s-expression text\n stackup Show typed board stackup\n graphics-defaults Show typed graphics defaults\n appearance Show typed editor appearance settings\n netclass Show typed netclass map for current board nets\n proto-coverage-board-read Print board-read command coverage vs proto\n board-read-report [--out P] Write markdown board reconstruction report\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 net-classes List project netclass definitions\n text-variables List text variables for current board document\n expand-text-variables Expand variables in provided text values\n Options: --text (repeatable)\n text-extents Measure text bounding box\n Options: --text \n text-as-shapes Convert text to rendered shapes\n Options: --text (repeatable)\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 types-pcb List PCB KiCad object type IDs from proto enum\n items-raw --type-id ... Dump raw Any payloads for requested item type IDs\n items-raw-all-pcb [--debug] Dump all PCB item payloads across all PCB object types\n pad-shape-polygon --pad-id ... --layer-id [--debug]\n Dump pad polygons on a target layer\n padstack-presence --item-id ... --layer-id ... [--debug]\n Check padstack shape presence matrix across layers\n title-block Show title block fields\n board-as-string Dump board as KiCad s-expression text\n selection-as-string Dump current selection as KiCad s-expression text\n stackup Show typed board stackup\n graphics-defaults Show typed graphics defaults\n appearance Show typed editor appearance settings\n netclass Show typed netclass map for current board nets\n proto-coverage-board-read Print board-read command coverage vs proto\n board-read-report [--out P] Write markdown board reconstruction report\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" ); } @@ -1427,6 +1498,11 @@ fn proto_coverage_board_read_rows() -> Vec<(&'static str, &'static str, &'static "implemented", "get_text_extents_raw/get_text_extents", ), + ( + "kiapi.common.commands.GetTextAsShapes", + "implemented", + "get_text_as_shapes_raw/get_text_as_shapes", + ), ( "kiapi.common.commands.GetItems", "implemented",