diff --git a/DEV.md b/DEV.md index d9c9edcc..a58066c9 100644 --- a/DEV.md +++ b/DEV.md @@ -92,7 +92,7 @@ Konnect/ │ │ ├── pcb_sync.rs # update_pcb_from_schematic: pure planner + one-commit IPC apply │ │ ├── sch_hierarchy.rs # 12 tools (typed Sheet model, sheet CRUD + hierarchy/page queries + pin lifecycle) │ │ ├── pcb_board.rs # 11 tools (S-expr file editing, IPC fallback, SVG logo import) -│ │ ├── pcb_components.rs # 18 tools (IPC real-time + safe headless single-placement fallback) +│ │ ├── pcb_components.rs # 19 tools (IPC real-time + safe headless single-placement fallback) │ │ ├── pcb_footprint_update.rs # library refresh planner + one-commit IPC apply │ │ ├── pcb_routing.rs # 12 tools (traces, vias, nets, netclasses) │ │ ├── pcb_export.rs # 13 tools (Gerber, PDF, 3D, DRC, DXF/GenCAD/IPC-2581/ODB++) @@ -303,7 +303,7 @@ Source: [`crates/konnect-core/src/observability.rs`](crates/konnect-core/src/obs ## Tool Routing (Starter Kit + On-Demand Loading) -The server does NOT expose all 205 tools (211 total with the 6 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: +The server does NOT expose all 206 tools (212 total with the 6 meta-tools) in `tools/list` by default — that would cost ~23K tokens of context on every listing. Instead: - **Startup**: only `STARTER_KIT` toolsets are pre-loaded (see `router/registry.rs::STARTER_KIT`). Currently: `project`, `config`. Combined with the 6 meta-tools, baseline `tools/list` is 20 tools ≈ 2K tokens. - **On demand**: the LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose a toolset's tools in subsequent `tools/list` responses. `unload_toolset(name)` prunes them when the task shifts. @@ -378,9 +378,9 @@ convention for other `kicad-cli`-calling code. ## Current Stats -- **19 toolsets, 205 tools** + 6 meta-tools (4 routing + 2 observability — see `tool-directory.md`) +- **19 toolsets, 206 tools** + 6 meta-tools (4 routing + 2 observability — see `tool-directory.md`) - Baseline `tools/list`: 20 tools / ~2K tokens (starter kit + meta-tools) -- Full-catalog `tools/list` (all loaded): 211 tools (205 registered + 6 meta) / ~25K tokens +- Full-catalog `tools/list` (all loaded): 212 tools (206 registered + 6 meta) / ~25K tokens - **0 IPC stubs** (all protobuf methods implemented) - **0 unimplemented tools** - **Specctra DSN/SES are PCB-editor operations**, not `kicad-cli` commands. Konnect diff --git a/README.md b/README.md index 440ff455..005ae9e9 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Rust binary — that lets Claude and other AI assistants design schematics and PCBs through the [Model Context Protocol](https://modelcontextprotocol.io) (MCP). -**205 tools across 19 on-demand toolsets.** Schematic capture, PCB layout and +**206 tools across 19 on-demand toolsets.** Schematic capture, PCB layout and routing, ERC/DRC, design-review audits, JLCPCB part search, reference circuits, and a full manufacturing export pipeline — with bundled skills and agents that teach Claude KiCAD conventions out of the box. @@ -70,7 +70,7 @@ through its own S-expression engine with atomic writes (write, fsync, rename), U preservation, and round-trip tests — no third-party schematic library with known gaps, no text-manipulation workarounds. -**Context economy is a feature.** Exposing all 205 tools to an LLM costs roughly 23K +**Context economy is a feature.** Exposing all 206 tools to an LLM costs roughly 23K tokens of context on every listing. Konnect's router loads a starter kit (~2K tokens) and lets the model pull in toolsets on demand — plus built-in observability (`get_recent_calls`, `server_stats`, JSONL call logs) so the model can diagnose its diff --git a/crates/konnect-core/src/router/registry.rs b/crates/konnect-core/src/router/registry.rs index fd70989c..d8f892a7 100644 --- a/crates/konnect-core/src/router/registry.rs +++ b/crates/konnect-core/src/router/registry.rs @@ -76,7 +76,7 @@ pub static ALL_TOOLSETS: &[ToolsetMeta] = &[ name: "pcb_components", description: "Place, refresh, move, rotate, flip, align, duplicate and repair PCB footprints; inspect pads; inspect and edit a placed footprint's graphics", category: "pcb", - tool_count: 18, + tool_count: 19, }, ToolsetMeta { name: "pcb_routing", diff --git a/crates/konnect-core/src/tools/pcb_components.rs b/crates/konnect-core/src/tools/pcb_components.rs index cda157e3..f967c65d 100644 --- a/crates/konnect-core/src/tools/pcb_components.rs +++ b/crates/konnect-core/src/tools/pcb_components.rs @@ -934,9 +934,11 @@ fn persist_board_replacement( write_atomic_if_unchanged(board_path, expected, replacement) } +#[derive(Clone, Copy)] enum FootprintPlacementUpdate { Move { x: f64, y: f64 }, Rotate { rotation: f64 }, + Set { x: f64, y: f64, rotation: f64 }, } /// Why a closed-board placement update could not be applied. @@ -1005,6 +1007,56 @@ fn update_closed_board_footprint( Ok(()) } +fn update_closed_board_footprints( + board_path: &Path, + placements: &[konnect_ipc::types::IpcFootprintPlacement], +) -> Result, ClosedBoardError> { + let content = + read_consistent(board_path).map_err(|error| ClosedBoardError::Io(error.into()))?; + let mut updated = content.clone(); + for placement in placements { + updated = prepare_closed_board_footprint_update( + &updated, + &placement.reference, + FootprintPlacementUpdate::Set { + x: placement.x, + y: placement.y, + rotation: placement.rotation, + }, + )?; + } + persist_board_replacement(board_path, &content, &updated) + .map_err(|error| ClosedBoardError::Io(error.into()))?; + // Report what was written, not what was asked: the file path normalizes + // the root angle to KiCad's (-180, 180] (a requested 270 is stored as + // -90), and the response has to say the number the file now holds. + let mut applied = Vec::with_capacity(placements.len()); + for placement in placements { + let root_at = find_direct_child_blocks(&updated, "kicad_pcb") + .into_iter() + .filter_map(|(start, end)| konnect_sexp::parse_sexp(&updated[start..end]).ok()) + .filter(|node| node.head() == Some("footprint")) + .find(|node| footprint_reference(node).as_deref() == Some(&placement.reference)) + .and_then(|node| { + let at = node.find("at")?; + Some((at.get_f64(1)?, at.get_f64(2)?, at.get_f64(3).unwrap_or(0.0))) + }) + .ok_or_else(|| { + ClosedBoardError::Unusable(format!( + "footprint '{}' was updated but cannot be read back from the written board", + placement.reference + )) + })?; + applied.push(konnect_ipc::types::IpcFootprintPlacement { + reference: placement.reference.clone(), + x: root_at.0, + y: root_at.1, + rotation: root_at.2, + }); + } + Ok(applied) +} + fn prepare_closed_board_footprint_update( content: &str, reference: &str, @@ -1083,6 +1135,7 @@ fn update_footprint_placement( FootprintPlacementUpdate::Rotate { rotation } => { (old_x, old_y, normalize_root_angle(rotation)) } + FootprintPlacementUpdate::Set { x, y, rotation } => (x, y, normalize_root_angle(rotation)), }; // Replace the root `(at …)` FIRST, while `at_start`/`at_end` still index @@ -1112,7 +1165,8 @@ fn update_footprint_placement( ); let updated = match update { FootprintPlacementUpdate::Move { .. } => updated, - FootprintPlacementUpdate::Rotate { rotation } => { + FootprintPlacementUpdate::Rotate { rotation } + | FootprintPlacementUpdate::Set { rotation, .. } => { apply_rotation_to_children(&updated, rotation - old_rotation) } }; @@ -1753,6 +1807,32 @@ pub fn tools() -> Vec { }), |args, ctx| async move { handle_rotate_component(args, ctx).await } ), + tool!( + "set_component_placements", + "Set X/Y positions and rotations for multiple existing footprints atomically. Uses one live KiCAD IPC update and one undo step when reachable; otherwise safely edits a closed board file once with revision checks.", + json!({ + "type": "object", + "properties": { + "board": { "type": "string" }, + "placements": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "reference": { "type": "string" }, + "x": { "type": "number", "description": "Target X coordinate in millimetres" }, + "y": { "type": "number", "description": "Target Y coordinate in millimetres" }, + "rotation": { "type": "number", "description": "Target absolute rotation in degrees" } + }, + "required": ["reference", "x", "y", "rotation"] + } + } + }, + "required": ["board", "placements"] + }), + |args, ctx| async move { handle_set_component_placements(args, ctx).await } + ), tool!( "flip_component", "Set a placed footprint to F.Cu or B.Cu with KiCAD-equivalent geometry mirroring. \ @@ -2282,6 +2362,104 @@ async fn handle_rotate_component( } } +fn invalid_placement(field: String, reason: impl Into) -> CallToolResult { + let reason = reason.into(); + CallToolResult::error_kind( + crate::mcp::error::ToolErrorKind::InvalidArgument { + field: field.clone(), + reason: reason.clone(), + }, + format!("Argument '{field}' is invalid: {reason}"), + ) +} + +fn parse_component_placements( + args: &serde_json::Value, +) -> Result, CallToolResult> { + let values = require_array(args, "placements")?; + if values.is_empty() { + return Err(invalid_placement( + "placements".to_string(), + "must contain at least one placement", + )); + } + + let mut references = HashSet::new(); + let mut placements = Vec::with_capacity(values.len()); + for (index, value) in values.iter().enumerate() { + let field = |name: &str| format!("placements[{index}].{name}"); + let Some(object) = value.as_object() else { + return Err(invalid_placement( + format!("placements[{index}]"), + "must be an object", + )); + }; + let reference = object + .get("reference") + .and_then(serde_json::Value::as_str) + .filter(|reference| !reference.is_empty()) + .ok_or_else(|| invalid_placement(field("reference"), "missing or empty"))? + .to_string(); + if !references.insert(reference.clone()) { + return Err(invalid_placement( + field("reference"), + format!("duplicate footprint reference '{reference}'"), + )); + } + let number = |name: &str| { + object + .get(name) + .and_then(serde_json::Value::as_f64) + .ok_or_else(|| invalid_placement(field(name), "missing or not a number")) + }; + placements.push(konnect_ipc::types::IpcFootprintPlacement { + reference, + x: number("x")?, + y: number("y")?, + rotation: number("rotation")?, + }); + } + Ok(placements) +} + +async fn handle_set_component_placements( + args: &serde_json::Value, + ctx: &ToolContext, +) -> anyhow::Result { + let board = get_path(args, "board")?; + let placements = match parse_component_placements(args) { + Ok(placements) => placements, + Err(error) => return Ok(error), + }; + + let placements_ipc = placements.clone(); + match attempt_ipc_write( + ctx.config.ipc_address.clone(), + &board, + "component placement batch", + move |client| client.set_footprint_placements(&placements_ipc), + ) + .await? + { + BoardWrite::Ipc(applied) => Ok(CallToolResult::json(&json!({ + "count": applied.len(), + "placements": applied, + "source": "ipc", + "undo": "One KiCad undo step reverses the whole placement batch." + }))), + BoardWrite::Refused(result) => Ok(result), + BoardWrite::File => match update_closed_board_footprints(&board, &placements) { + Ok(applied) => Ok(CallToolResult::json(&json!({ + "count": applied.len(), + "placements": applied, + "source": "file", + "warning": "KiCad IPC was not reachable, so the closed board file was edited once with a revision check." + }))), + Err(error) => Ok(error.into_result()), + }, + } +} + async fn handle_flip_component( args: &serde_json::Value, ctx: &ToolContext, @@ -4433,6 +4611,101 @@ mod tests { assert!(konnect_sexp::parse_sexp(&written).is_ok()); } + #[tokio::test] + async fn unreachable_ipc_sets_multiple_placements_with_one_file_write() { + let tmp = tempfile::tempdir().unwrap(); + let board = fallback_fixture(tmp.path()); + for (reference, x) in [("R1", 10.0), ("R2", 20.0)] { + let placed = handle_place_component( + &json!({ + "board": board.to_string_lossy(), + "footprint": "Resistor_SMD:R_0805_2012Metric", + "reference": reference, + "x": x, + "y": 20.0, + "rotation": 0.0, + }), + &test_ctx(), + ) + .await + .unwrap(); + assert!(!placed.is_error, "{reference}: {:?}", placed.content); + } + + let result = handle_set_component_placements( + &json!({ + "board": board.to_string_lossy(), + "placements": [ + {"reference": "R1", "x": 40.0, "y": 50.0, "rotation": 270.0}, + {"reference": "R2", "x": 60.0, "y": 70.0, "rotation": 45.0} + ] + }), + &test_ctx(), + ) + .await + .unwrap(); + + assert!(!result.is_error, "{:?}", result.content); + let response: serde_json::Value = + serde_json::from_str(&result_text(&result)).expect("batch result must be JSON"); + assert_eq!(response["source"], "file"); + assert_eq!(response["count"], 2); + let written = std::fs::read_to_string(&board).unwrap(); + assert!(written.contains("(at 40 50 -90)"), "{written}"); + assert!(written.contains("(at 60 70 45)"), "{written}"); + assert!(konnect_sexp::parse_sexp(&written).is_ok()); + + // The response reports what the file now holds, not what was asked: + // the requested 270 is stored (and therefore reported) as -90. An + // echoed 270 here and a -90 in the file would be two different + // answers for one final state. + assert_eq!(response["placements"][0]["reference"], "R1"); + assert_eq!(response["placements"][0]["rotation"], -90.0, "{response}"); + assert_eq!(response["placements"][1]["rotation"], 45.0); + assert_eq!(response["placements"][0]["x"], 40.0); + assert_eq!(response["placements"][1]["y"], 70.0); + } + + #[tokio::test] + async fn placement_batch_is_all_or_nothing_for_missing_and_duplicate_references() { + let tmp = tempfile::tempdir().unwrap(); + let board = placed_fallback_fixture(tmp.path()).await; + let before = std::fs::read_to_string(&board).unwrap(); + + let missing = handle_set_component_placements( + &json!({ + "board": board.to_string_lossy(), + "placements": [ + {"reference": "R1", "x": 40.0, "y": 50.0, "rotation": 90.0}, + {"reference": "R404", "x": 60.0, "y": 70.0, "rotation": 0.0} + ] + }), + &test_ctx(), + ) + .await + .unwrap(); + assert!(missing.is_error); + assert!(result_text(&missing).contains("R404")); + assert_eq!(std::fs::read_to_string(&board).unwrap(), before); + + let duplicate = handle_set_component_placements( + &json!({ + "board": board.to_string_lossy(), + "placements": [ + {"reference": "R1", "x": 40.0, "y": 50.0, "rotation": 90.0}, + {"reference": "R1", "x": 60.0, "y": 70.0, "rotation": 0.0} + ] + }), + &test_ctx(), + ) + .await + .unwrap(); + let text = result_text(&duplicate); + assert!(duplicate.is_error); + assert!(text.contains("invalid_argument") && text.contains("placements[1].reference")); + assert_eq!(std::fs::read_to_string(&board).unwrap(), before); + } + #[tokio::test] async fn closed_board_move_and_rotate_reject_a_missing_reference_without_writing() { let tmp = tempfile::tempdir().unwrap(); @@ -4475,11 +4748,11 @@ mod tests { .unwrap(); assert!(rotated.is_error); assert!(result_text(&rotated).contains("R404")); - assert_eq!(std::fs::read_to_string(board).unwrap(), before); + assert_eq!(std::fs::read_to_string(&board).unwrap(), before); } #[tokio::test] - async fn reachable_rejection_prevents_move_and_rotate_file_fallbacks() { + async fn reachable_rejection_prevents_placement_file_fallbacks() { let tmp = tempfile::tempdir().unwrap(); let board = placed_fallback_fixture(tmp.path()).await; let before = std::fs::read_to_string(&board).unwrap(); @@ -4523,7 +4796,22 @@ mod tests { .unwrap(); assert!(rotated.is_error); assert!(result_text(&rotated).contains("not modified")); - assert_eq!(std::fs::read_to_string(board).unwrap(), before); + assert_eq!(std::fs::read_to_string(&board).unwrap(), before); + + let batch = handle_set_component_placements( + &json!({ + "board": board.to_string_lossy(), + "placements": [ + {"reference": "R1", "x": 40.0, "y": 50.0, "rotation": 90.0} + ] + }), + &ctx, + ) + .await + .unwrap(); + assert!(batch.is_error); + assert!(result_text(&batch).contains("not modified")); + assert_eq!(std::fs::read_to_string(&board).unwrap(), before); } const FLIP_FOOTPRINT: &str = r#"(footprint "Test:Flip" diff --git a/crates/konnect-ipc/src/client.rs b/crates/konnect-ipc/src/client.rs index e24c8a20..a046df13 100644 --- a/crates/konnect-ipc/src/client.rs +++ b/crates/konnect-ipc/src/client.rs @@ -1470,6 +1470,162 @@ impl KiCadIpcClient { anyhow::bail!("Footprint '{}' not found", reference) } + /// Set the complete placement of several footprints in one KiCad undo + /// transaction and one `UpdateItems` request. + /// + /// KiCad serializes footprint children in absolute board coordinates, so + /// every child must receive the same rigid transform as its parent. Doing + /// that from one board snapshot also avoids the transient state and the + /// two IPC round trips produced by a separate move followed by a rotate. + /// Returns the placements as the board holds them after the commit — + /// read back from KiCad, never echoed from the request (the #294/#232 + /// standard). KiCad may normalize what it stores (angles in particular), + /// so the response has to come from the result. + pub fn set_footprint_placements( + &self, + placements: &[IpcFootprintPlacement], + ) -> Result> { + if placements.is_empty() { + return Ok(Vec::new()); + } + + let mut requested = std::collections::HashSet::new(); + for placement in placements { + if !requested.insert(placement.reference.as_str()) { + anyhow::bail!( + "placement request contains duplicate footprint reference '{}'", + placement.reference + ); + } + } + + self.run_commit("Set component placements", |client| { + let items = client.get_items(kiapi::common::types::KiCadObjectType::KotPcbFootprint)?; + let mut matched = std::collections::HashSet::new(); + let mut updates = Vec::with_capacity(placements.len()); + + for item in items { + if !crate::builders::any_is(&item, "kiapi.board.types.FootprintInstance") { + continue; + } + let mut footprint = + kiapi::board::types::FootprintInstance::decode(item.value.as_slice())?; + let reference = footprint + .reference_field + .as_ref() + .and_then(|field| field.text.as_ref()) + .and_then(|text| text.text.as_ref()) + .map(|text| text.text.as_str()) + .unwrap_or(""); + let Some(target) = placements + .iter() + .find(|placement| placement.reference == reference) + else { + continue; + }; + if !matched.insert(reference.to_string()) { + anyhow::bail!( + "footprint reference '{}' appears more than once on the board", + reference + ); + } + + let old_position = footprint.position.unwrap_or_default(); + let old_rotation = footprint + .orientation + .as_ref() + .map(|angle| angle.value_degrees) + .unwrap_or(0.0); + let rotation_delta = target.rotation - old_rotation; + if rotation_delta != 0.0 { + crate::transform::transform_footprint_children( + &mut footprint, + &crate::transform::Xform::Rotate { + cx_nm: old_position.x_nm, + cy_nm: old_position.y_nm, + delta_deg: rotation_delta, + }, + )?; + } + + let new_position = crate::builders::vec2(target.x, target.y); + let dx_nm = new_position.x_nm - old_position.x_nm; + let dy_nm = new_position.y_nm - old_position.y_nm; + if dx_nm != 0 || dy_nm != 0 { + crate::transform::transform_footprint_children( + &mut footprint, + &crate::transform::Xform::Translate { dx_nm, dy_nm }, + )?; + } + footprint.position = Some(new_position); + footprint.orientation = Some(kiapi::common::types::Angle { + value_degrees: target.rotation, + }); + updates.push(crate::builders::pack_any( + &footprint, + "kiapi.board.types.FootprintInstance", + )); + } + + let missing: Vec<_> = placements + .iter() + .filter(|placement| !matched.contains(&placement.reference)) + .map(|placement| placement.reference.as_str()) + .collect(); + if !missing.is_empty() { + anyhow::bail!( + "footprint{} {} not found on board", + if missing.len() == 1 { "" } else { "s" }, + missing.join(", ") + ); + } + + client.update_items(updates) + })?; + + // Post-commit read-back: report what the board holds, in request order. + let items = self.get_items(kiapi::common::types::KiCadObjectType::KotPcbFootprint)?; + let mut held = std::collections::HashMap::new(); + for item in &items { + if !crate::builders::any_is(item, "kiapi.board.types.FootprintInstance") { + continue; + } + let footprint = kiapi::board::types::FootprintInstance::decode(item.value.as_slice())?; + let reference = footprint + .reference_field + .as_ref() + .and_then(|field| field.text.as_ref()) + .and_then(|text| text.text.as_ref()) + .map(|text| text.text.clone()) + .unwrap_or_default(); + let position = footprint.position.unwrap_or_default(); + held.insert( + reference.clone(), + IpcFootprintPlacement { + reference, + x: nm_to_mm(position.x_nm), + y: nm_to_mm(position.y_nm), + rotation: footprint + .orientation + .as_ref() + .map(|angle| angle.value_degrees) + .unwrap_or(0.0), + }, + ); + } + placements + .iter() + .map(|placement| { + held.remove(&placement.reference).with_context(|| { + format!( + "footprint '{}' was updated but is missing from the post-commit read-back", + placement.reference + ) + }) + }) + .collect() + } + /// Update the visible value field of an existing footprint. pub fn set_footprint_value(&self, reference: &str, value: &str) -> Result<()> { let items = self.get_items(kiapi::common::types::KiCadObjectType::KotPcbFootprint)?; diff --git a/crates/konnect-ipc/src/types.rs b/crates/konnect-ipc/src/types.rs index 9f3ede2c..f30f1c81 100644 --- a/crates/konnect-ipc/src/types.rs +++ b/crates/konnect-ipc/src/types.rs @@ -41,6 +41,19 @@ pub struct IpcTitleBlock { pub company: String, } +/// Complete target placement for one existing footprint. +/// +/// Keeping the four values together lets the IPC client transform all selected +/// footprints from one board snapshot and publish them in one undoable update, +/// instead of issuing a move and a rotation as separate round trips. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct IpcFootprintPlacement { + pub reference: String, + pub x: f64, + pub y: f64, + pub rotation: f64, +} + #[derive(Debug, Clone)] pub struct IpcPadDefinition { pub number: String, diff --git a/crates/konnect-ipc/tests/footprint_transform_test.rs b/crates/konnect-ipc/tests/footprint_transform_test.rs index 9f3e64d1..22b36d2f 100644 --- a/crates/konnect-ipc/tests/footprint_transform_test.rs +++ b/crates/konnect-ipc/tests/footprint_transform_test.rs @@ -142,9 +142,15 @@ type CapturedUpdate = Arc>>; /// Mock KiCAD serving `fp` for GetItems and recording the UpdateItems it /// receives. fn spawn_footprint_mock(fp: kiapi::board::types::FootprintInstance) -> (MockKicad, CapturedUpdate) { + spawn_footprints_mock(vec![fp]) +} + +fn spawn_footprints_mock( + footprints: Vec, +) -> (MockKicad, CapturedUpdate) { let captured: CapturedUpdate = Arc::new(Mutex::new(None)); let captured_in_mock = captured.clone(); - let current = Arc::new(Mutex::new(fp)); + let current = Arc::new(Mutex::new(footprints)); let current_in_mock = current.clone(); let mock = spawn_mock(move |req| { @@ -169,15 +175,33 @@ fn spawn_footprint_mock(fp: kiapi::board::types::FootprintInstance) -> (MockKica let resp = kiapi::common::commands::GetItemsResponse { header: None, status: kiapi::common::types::ItemRequestStatus::IrsOk as i32, - items: vec![builders::pack_any( - &*current_in_mock.lock().unwrap(), - "kiapi.board.types.FootprintInstance", - )], + items: current_in_mock + .lock() + .unwrap() + .iter() + .map(|footprint| { + builders::pack_any(footprint, "kiapi.board.types.FootprintInstance") + }) + .collect(), }; Some(reply_with(builders::pack_any( &resp, "kiapi.common.commands.GetItemsResponse", ))) + } else if msg.type_url.ends_with("BeginCommit") { + Some(reply_with(builders::pack_any( + &kiapi::common::commands::BeginCommitResponse { + id: Some(kiapi::common::types::Kiid { + value: "placement-commit".to_string(), + }), + }, + "kiapi.common.commands.BeginCommitResponse", + ))) + } else if msg.type_url.ends_with("EndCommit") { + Some(reply_with(builders::pack_any( + &kiapi::common::commands::EndCommitResponse {}, + "kiapi.common.commands.EndCommitResponse", + ))) } else if msg.type_url.ends_with("UpdateItems") { let update = kiapi::common::commands::UpdateItems::decode(msg.value.as_slice()).unwrap(); @@ -193,9 +217,23 @@ fn spawn_footprint_mock(fp: kiapi::board::types::FootprintInstance) -> (MockKica item: Some(item), }) .collect(); - if let Some(item) = update.items.first() { - *current_in_mock.lock().unwrap() = - kiapi::board::types::FootprintInstance::decode(item.value.as_slice()).unwrap(); + { + // Keep the mock stateful: a later GetItems must observe what + // UpdateItems wrote (the pad-readback test relies on it), and + // a batch update replaces every matching footprint. + let mut held = current_in_mock.lock().unwrap(); + for item in &update.items { + let incoming = + kiapi::board::types::FootprintInstance::decode(item.value.as_slice()) + .unwrap(); + let reference = mock_reference(&incoming); + if let Some(slot) = held + .iter_mut() + .find(|existing| mock_reference(existing) == reference) + { + *slot = incoming; + } + } } *captured_in_mock.lock().unwrap() = Some(update); Some(reply_with(builders::pack_any( @@ -214,6 +252,15 @@ fn spawn_footprint_mock(fp: kiapi::board::types::FootprintInstance) -> (MockKica (mock, captured) } +fn mock_reference(fp: &kiapi::board::types::FootprintInstance) -> String { + fp.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_default() +} + fn pad_positions_mm(fp: &kiapi::board::types::FootprintInstance) -> Vec<(f64, f64)> { fp.definition .as_ref() @@ -326,3 +373,72 @@ fn footprint_pad_readback_observes_the_updated_live_state_after_a_move() { vec![(49.0, 50.0), (51.0, 50.0)] ); } + +#[test] +fn placement_batch_moves_and_rotates_multiple_footprints_in_one_update() { + let r1 = mk_footprint_r1(); + let mut r2 = mk_footprint_r1(); + konnect_ipc::transform::transform_footprint_children( + &mut r2, + &konnect_ipc::transform::Xform::Translate { + dx_nm: 100_000_000, + dy_nm: 0, + }, + ) + .unwrap(); + r2.position = Some(builders::vec2(200.0, 100.0)); + r2.reference_field + .as_mut() + .unwrap() + .text + .as_mut() + .unwrap() + .text + .as_mut() + .unwrap() + .text = "R2".to_string(); + + let (mock, captured) = spawn_footprints_mock(vec![r1, r2]); + let client = KiCadIpcClient::new(&mock.url); + client + .set_footprint_placements(&[ + konnect_ipc::types::IpcFootprintPlacement { + reference: "R1".to_string(), + x: 50.0, + y: 50.0, + rotation: 90.0, + }, + konnect_ipc::types::IpcFootprintPlacement { + reference: "R2".to_string(), + x: 250.0, + y: 150.0, + rotation: 180.0, + }, + ]) + .unwrap(); + + let update = captured.lock().unwrap().take().expect("UpdateItems sent"); + assert_eq!( + update.items.len(), + 2, + "one request must carry both footprints" + ); + let sent: Vec<_> = update + .items + .iter() + .map(|item| kiapi::board::types::FootprintInstance::decode(item.value.as_slice()).unwrap()) + .collect(); + let placements: Vec<_> = sent + .iter() + .map(|footprint| { + let position = footprint.position.unwrap(); + ( + builders::nm_to_mm(position.x_nm), + builders::nm_to_mm(position.y_nm), + footprint.orientation.as_ref().unwrap().value_degrees, + ) + }) + .collect(); + assert_eq!(placements, vec![(50.0, 50.0, 90.0), (250.0, 150.0, 180.0)]); + assert_eq!(pad_positions_mm(&sent[0]), vec![(50.0, 51.0), (50.0, 49.0)]); +} diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 154637ee..55c97d39 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -192,7 +192,7 @@ The fix for those clients is to make the *first* listing complete: ``` in `konnect.toml` in the working directory, or a `settings.json` beside the binary. Every toolset is then loaded at -startup, so `tools/list` carries all 211 tools from the first call. +startup, so `tools/list` carries all 212 tools from the first call. It is off by default because it costs what the router exists to save: roughly 25K tokens per listing instead of ~2K. Turn it on only if your client needs it. diff --git a/packaging/metadata.json b/packaging/metadata.json index cd8fe503..40285168 100644 --- a/packaging/metadata.json +++ b/packaging/metadata.json @@ -1,7 +1,7 @@ { "$schema": "https://go.kicad.org/pcm/schemas/v1", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 205 tools organized into on-demand toolsets.", + "description": "AI-assisted PCB design via the Model Context Protocol. Enables Claude and other AI assistants to design schematics and PCBs with 206 tools organized into on-demand toolsets.", "description_full": "Konnect exposes a complete set of KiCAD design tools to AI assistants via the Model Context Protocol (MCP). It supports schematic editing, PCB layout, routing, library management, JLCPCB part search, Freerouting installation checks, ERC/DRC, design review audits, and full export pipelines. Tools are organized into 19 toolsets loaded on demand so the AI only sees relevant tools at once.", "identifier": "com.github.mixelpixx.konnect", "type": "plugin", diff --git a/plugin/plugin.json b/plugin/plugin.json index 87de6e1c..73831772 100644 --- a/plugin/plugin.json +++ b/plugin/plugin.json @@ -1,7 +1,7 @@ { "identifier": "com.github.mixelpixx.konnect", "name": "Konnect", - "description": "AI-assisted PCB design via the Model Context Protocol. 205 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", + "description": "AI-assisted PCB design via the Model Context Protocol. 206 tools for schematic editing, PCB layout, routing, design review, and manufacturing export.", "runtime": { "type": "exec" }, diff --git a/tool-directory.md b/tool-directory.md index a278dedf..4af6fa60 100644 --- a/tool-directory.md +++ b/tool-directory.md @@ -13,7 +13,7 @@ Compatibility notes for removed or narrowed arguments are recorded in ## Overview - **19 toolsets** organized into 10 categories -- **205 registered tools** + **6 always-visible meta-tools** = **211 total** +- **206 registered tools** + **6 always-visible meta-tools** = **212 total** - **Discovery pattern**: the server pre-loads only the **starter kit** (`project`, `config`) so baseline `tools/list` costs ~2K tokens instead of ~23K. The LLM reads `list_toolboxes` → calls `load_toolset(name)` to expose additional tools on demand; `unload_toolset(name)` prunes them. `tools/list_changed` is notified on every mutation. If the LLM calls a tool whose toolset isn't loaded, the error names the owning toolset so recovery is a single `load_toolset` hop. `load_toolset` also accepts an array of names to load several toolsets with a single `tools/list` refresh. - **Observability**: every `tools/call` is recorded — ring buffer of the last 100 calls + per-tool counters + JSONL at `/logs/calls.jsonl`. The LLM self-diagnoses via `get_recent_calls` and `server_stats`. @@ -220,7 +220,7 @@ Six tools, grouped into *discovery/routing* and *observability*. | `add_zone` | Add a copper fill zone polygon on a specified layer and net, with optional `name`, `priority` and `pad_connection` (`solid`/`thermal`/`none`). Tries KiCad IPC first — a live board gets the zone through the API and a refill, so it appears immediately and is undoable — and falls back to an S-expression file insert only when no live KiCad answers, reporting `source` and a `warning` when it does. Refuses a net the board does not declare rather than binding copper to net 0, and refuses outright if KiCad answers but rejects the request. | | `import_svg_logo` | Import an SVG file as filled silkscreen/copper artwork (curves flattened to polygons). | -### `pcb_components` · 18 tools +### `pcb_components` · 19 tools **Purpose:** Place, refresh, move, rotate, flip, align, duplicate and repair PCB footprints; inspect pads; inspect and edit a placed footprint's graphics. **Source:** [`crates/konnect-core/src/tools/pcb_components.rs`](crates/konnect-core/src/tools/pcb_components.rs) @@ -229,6 +229,7 @@ Six tools, grouped into *discovery/routing* and *observability*. | `place_component` | Place a footprint through live KiCAD IPC when reachable, or use a revision-aware file fallback when no KiCAD process can hold the board open. The fallback preserves complete footprint content and rejects duplicate references. | | `move_component` | Move a placed footprint through live KiCAD IPC when reachable, or use a revision-aware closed-board file fallback. | | `rotate_component` | Set a placed footprint's absolute rotation through live KiCAD IPC when reachable, or use a revision-aware closed-board file fallback that updates child angles. | +| `set_component_placements` | Set X/Y positions and absolute rotations for multiple existing footprints atomically, using one live KiCAD update and one undo step or one revision-aware closed-board write. | | `flip_component` | Set a placed footprint to F.Cu or B.Cu on a closed board with KiCAD-equivalent geometry mirroring and revision checks; refuses live-editor races and unsupported geometry. | | `delete_component` | Remove a footprint from the board via KiCAD IPC. | | `edit_component` | Update the value or other properties of a placed footprint via KiCAD IPC. |