From c7d6d0126f0b1032576215fb4332d2694edb876b Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Sun, 1 Mar 2026 18:37:35 +0100 Subject: [PATCH 1/7] fix(bom): use GetUniStringLibId() instead of str() for footprint in BOM export str(module.GetFPID()) returns the Swig proxy representation like "" instead of the actual footprint name. GetUniStringLibId() returns the proper "Library:Footprint" string. --- python/commands/export.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/commands/export.py b/python/commands/export.py index e0749ed..d773431 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -474,7 +474,7 @@ class ExportCommands: component = { "reference": module.GetReference(), "value": module.GetValue(), - "footprint": str(module.GetFPID()), + "footprint": module.GetFPID().GetUniStringLibId(), "layer": self.board.GetLayerName(module.GetLayer()) } From 246050001ac59ce8a1faa54673d3d9c984eb1d40 Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Sun, 1 Mar 2026 18:37:55 +0100 Subject: [PATCH 2/7] fix(nets): use GetNetClassName() instead of GetClassName() on NETINFO_ITEM NETINFO_ITEM objects don't have a GetClassName() method, causing an AttributeError crash when listing nets. The correct method is GetNetClassName() which returns the net class name string. --- python/commands/routing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/commands/routing.py b/python/commands/routing.py index d64d19c..202f840 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -509,7 +509,7 @@ class RoutingCommands: { "name": net.GetNetname(), "code": net.GetNetCode(), - "class": net.GetClassName(), + "class": net.GetNetClassName(), } ) From ec1939bef4574771af5841fa127559a902483b08 Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Sun, 1 Mar 2026 18:40:24 +0100 Subject: [PATCH 3/7] fix(copper-pour): add outline parameter and fallback to board outline Two issues fixed: 1. TypeScript schema was missing the outline parameter entirely, so MCP clients couldn't send pour boundary points. 2. Python code read "points" key but schema defined "outline" key. Now accepts "outline" (with "points" as fallback for backwards compatibility). When no outline is provided, automatically uses the board edge bounding box as the pour boundary. --- python/commands/routing.py | 27 +++++++++++++++++++++------ src/tools/routing.ts | 6 ++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/python/commands/routing.py b/python/commands/routing.py index 202f840..2aa366a 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -1058,16 +1058,31 @@ class RoutingCommands: net = params.get("net") clearance = params.get("clearance") min_width = params.get("minWidth", 0.2) - points = params.get("points", []) + points = params.get("outline", params.get("points", [])) priority = params.get("priority", 0) fill_type = params.get("fillType", "solid") # solid or hatched + # If no outline provided, use board outline if not points or len(points) < 3: - return { - "success": False, - "message": "Missing points", - "errorDetails": "At least 3 points are required for copper pour outline", - } + board_box = self.board.GetBoardEdgesBoundingBox() + if board_box.GetWidth() > 0 and board_box.GetHeight() > 0: + scale = 1000000 # nm to mm + x1 = board_box.GetX() / scale + y1 = board_box.GetY() / scale + x2 = (board_box.GetX() + board_box.GetWidth()) / scale + y2 = (board_box.GetY() + board_box.GetHeight()) / scale + points = [ + {"x": x1, "y": y1}, + {"x": x2, "y": y1}, + {"x": x2, "y": y2}, + {"x": x1, "y": y2}, + ] + else: + return { + "success": False, + "message": "Missing outline", + "errorDetails": "Provide an outline array or add a board outline first", + } # Get layer ID layer_id = self.board.GetLayerID(layer) diff --git a/src/tools/routing.ts b/src/tools/routing.ts index 691a903..f086608 100644 --- a/src/tools/routing.ts +++ b/src/tools/routing.ts @@ -105,6 +105,12 @@ export function registerRoutingTools( layer: z.string().describe("PCB layer"), net: z.string().describe("Net name"), clearance: z.number().optional().describe("Clearance in mm"), + outline: z + .array(z.object({ x: z.number(), y: z.number() })) + .optional() + .describe( + "Array of {x, y} points defining the pour boundary. If omitted, the board outline is used.", + ), }, async (args: any) => { const result = await callKicadScript("add_copper_pour", args); From 4e70342eae7900065c4ffdcf56cf299607f962a1 Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Sun, 1 Mar 2026 18:40:45 +0100 Subject: [PATCH 4/7] fix(drc): extract violation coordinates from kicad-cli JSON items kicad-cli DRC JSON output stores coordinates in items[].pos.x/y, not at the violation top level. The code was reading violation.x/y which don't exist, falling back to (0, 0) for every violation. Now correctly reads the position from the first item in each violation entry. --- python/commands/design_rules.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/python/commands/design_rules.py b/python/commands/design_rules.py index 04984c8..71eb7aa 100644 --- a/python/commands/design_rules.py +++ b/python/commands/design_rules.py @@ -267,14 +267,21 @@ class DesignRuleCommands: vtype = violation.get("type", "unknown") vseverity = violation.get("severity", "error") + # Extract location from first item's pos (kicad-cli JSON format) + items = violation.get("items", []) + loc_x, loc_y = 0, 0 + if items and "pos" in items[0]: + loc_x = items[0]["pos"].get("x", 0) + loc_y = items[0]["pos"].get("y", 0) + violations.append( { "type": vtype, "severity": vseverity, "message": violation.get("description", ""), "location": { - "x": violation.get("x", 0), - "y": violation.get("y", 0), + "x": loc_x, + "y": loc_y, "unit": "mm", }, } From a018575bbc19222f8d0eeae918617d03f3a03a21 Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Sun, 1 Mar 2026 18:41:39 +0100 Subject: [PATCH 5/7] fix(mounting-hole): generate unique references MH1, MH2, etc. All mounting holes were assigned the same reference "MH", violating PCB conventions and causing conflicts. Now queries existing footprints to find the next available MH number. --- python/commands/board/outline.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/python/commands/board/outline.py b/python/commands/board/outline.py index b0bf332..e826000 100644 --- a/python/commands/board/outline.py +++ b/python/commands/board/outline.py @@ -190,9 +190,17 @@ class BoardOutlineCommands: diameter_nm = int(diameter * scale) pad_diameter_nm = int(pad_diameter * scale) if pad_diameter else diameter_nm + scale # 1mm larger by default - # Create footprint for mounting hole + # Create footprint for mounting hole with unique reference + existing_mh = [ + fp.GetReference() for fp in self.board.GetFootprints() + if fp.GetReference().startswith("MH") + ] + next_num = 1 + while f"MH{next_num}" in existing_mh: + next_num += 1 + module = pcbnew.FOOTPRINT(self.board) - module.SetReference(f"MH") + module.SetReference(f"MH{next_num}") module.SetValue(f"MountingHole_{diameter}mm") # Create the pad for the hole From 61356d42cb3cf0232d62573fd3085a4114c0184c Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Sun, 1 Mar 2026 18:43:20 +0100 Subject: [PATCH 6/7] fix(schematic): rewrite component lookup to handle single-line files The previous line-based parser (split("\n") + line-by-line regex) failed when the MCP server itself generates .kicad_sch files as a single line. The new implementation works directly on the raw content string using parenthesis-depth matching to find symbol blocks, making it independent of formatting/whitespace. --- python/kicad_interface.py | 95 ++++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 45 deletions(-) diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 2fde2a7..94b63af 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -762,61 +762,66 @@ class KiCADInterface: return {"success": False, "message": f"Schematic not found: {schematic_path}"} with open(sch_file, "r", encoding="utf-8") as f: - lines = f.read().split("\n") + content = f.read() - # Find lib_symbols range to skip - lib_sym_start, lib_sym_end = None, None - depth = 0 - for i, line in enumerate(lines): - if "(lib_symbols" in line and lib_sym_start is None: - lib_sym_start = i - depth = sum(1 for c in line if c == "(") - sum(1 for c in line if c == ")") - elif lib_sym_start is not None and lib_sym_end is None: - depth += sum(1 for c in line if c == "(") - sum(1 for c in line if c == ")") - if depth == 0: - lib_sym_end = i - break + def find_matching_paren(s, start): + """Find the position of the closing paren matching the opening paren at start.""" + depth = 0 + i = start + while i < len(s): + if s[i] == '(': + depth += 1 + elif s[i] == ')': + depth -= 1 + if depth == 0: + return i + i += 1 + return -1 - # Find the placed symbol block + # Skip lib_symbols section + lib_sym_pos = content.find("(lib_symbols") + lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 + + # Find placed symbol blocks that match the reference + # Search for (symbol (lib_id "...") ... (property "Reference" "" ...) ...) block_start = block_end = None - i = 0 - while i < len(lines): - if lib_sym_start is not None and lib_sym_end is not None: - if lib_sym_start <= i <= lib_sym_end: - i += 1 - continue - if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", lines[i]): - b_start = i - b_depth = sum(1 for c in lines[i] if c == "(") - sum(1 for c in lines[i] if c == ")") - j = i + 1 - while j < len(lines) and b_depth > 0: - b_depth += sum(1 for c in lines[j] if c == "(") - sum(1 for c in lines[j] if c == ")") - j += 1 - b_end = j - 1 - block_text = "\n".join(lines[b_start:b_end + 1]) - if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text): - block_start, block_end = b_start, b_end - break - i = b_end + 1 + search_start = 0 + pattern = re.compile(r'\(symbol\s+\(lib_id\s+"') + while True: + m = pattern.search(content, search_start) + if not m: + break + pos = m.start() + # Skip if inside lib_symbols section + if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end: + search_start = lib_sym_end + 1 continue - i += 1 + end = find_matching_paren(content, pos) + if end < 0: + search_start = pos + 1 + continue + block_text = content[pos:end + 1] + if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text): + block_start, block_end = pos, end + break + search_start = end + 1 if block_start is None: return {"success": False, "message": f"Component '{reference}' not found in schematic"} - # Apply in-place property updates within the block - for k in range(block_start, block_end + 1): - line = lines[k] - if new_footprint is not None and re.match(r'\s*\(property\s+"Footprint"\s+"', line): - line = re.sub(r'(\(property\s+"Footprint"\s+)"[^"]*"', rf'\1"{new_footprint}"', line) - if new_value is not None and re.match(r'\s*\(property\s+"Value"\s+"', line): - line = re.sub(r'(\(property\s+"Value"\s+)"[^"]*"', rf'\1"{new_value}"', line) - if new_reference is not None and re.match(r'\s*\(property\s+"Reference"\s+"', line): - line = re.sub(r'(\(property\s+"Reference"\s+)"[^"]*"', rf'\1"{new_reference}"', line) - lines[k] = line + # Apply property replacements within the found block + block_text = content[block_start:block_end + 1] + if new_footprint is not None: + block_text = re.sub(r'(\(property\s+"Footprint"\s+)"[^"]*"', rf'\1"{new_footprint}"', block_text) + if new_value is not None: + block_text = re.sub(r'(\(property\s+"Value"\s+)"[^"]*"', rf'\1"{new_value}"', block_text) + if new_reference is not None: + block_text = re.sub(r'(\(property\s+"Reference"\s+)"[^"]*"', rf'\1"{new_reference}"', block_text) + + content = content[:block_start] + block_text + content[block_end + 1:] with open(sch_file, "w", encoding="utf-8") as f: - f.write("\n".join(lines)) + f.write(content) changes = {k: v for k, v in {"footprint": new_footprint, "value": new_value, "reference": new_reference}.items() if v is not None} logger.info(f"Edited schematic component {reference}: {changes}") From 2dd9de6a52a998aa8247acc7c700c8443400da13 Mon Sep 17 00:00:00 2001 From: Roman PASSLER Date: Sun, 1 Mar 2026 19:15:32 +0100 Subject: [PATCH 7/7] style: apply black formatting to changed files --- python/commands/board/outline.py | 197 ++++++++++++++++++------------- python/commands/export.py | 161 ++++++++++++++----------- python/commands/routing.py | 46 ++++++-- python/kicad_interface.py | 144 ++++++++++++++++------ 4 files changed, 356 insertions(+), 192 deletions(-) diff --git a/python/commands/board/outline.py b/python/commands/board/outline.py index e826000..d003213 100644 --- a/python/commands/board/outline.py +++ b/python/commands/board/outline.py @@ -7,7 +7,8 @@ import logging import math from typing import Dict, Any, Optional -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class BoardOutlineCommands: """Handles board outline operations""" @@ -23,7 +24,7 @@ class BoardOutlineCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } shape = params.get("shape", "rectangle") @@ -40,46 +41,54 @@ class BoardOutlineCommands: return { "success": False, "message": "Invalid shape", - "errorDetails": f"Shape '{shape}' not supported" + "errorDetails": f"Shape '{shape}' not supported", } # Convert to internal units (nanometers) scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm - + # Create drawing for edge cuts edge_layer = self.board.GetLayerID("Edge.Cuts") - + if shape == "rectangle": if width is None or height is None: return { "success": False, "message": "Missing dimensions", - "errorDetails": "Both width and height are required for rectangle" + "errorDetails": "Both width and height are required for rectangle", } width_nm = int(width * scale) height_nm = int(height * scale) center_x_nm = int(center_x * scale) center_y_nm = int(center_y * scale) - + # Create rectangle - top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2) - top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2) - bottom_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm + height_nm // 2) - bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2) - + top_left = pcbnew.VECTOR2I( + center_x_nm - width_nm // 2, center_y_nm - height_nm // 2 + ) + top_right = pcbnew.VECTOR2I( + center_x_nm + width_nm // 2, center_y_nm - height_nm // 2 + ) + bottom_right = pcbnew.VECTOR2I( + center_x_nm + width_nm // 2, center_y_nm + height_nm // 2 + ) + bottom_left = pcbnew.VECTOR2I( + center_x_nm - width_nm // 2, center_y_nm + height_nm // 2 + ) + # Add lines for rectangle self._add_edge_line(top_left, top_right, edge_layer) self._add_edge_line(top_right, bottom_right, edge_layer) self._add_edge_line(bottom_right, bottom_left, edge_layer) self._add_edge_line(bottom_left, top_left, edge_layer) - + elif shape == "rounded_rectangle": if width is None or height is None: return { "success": False, "message": "Missing dimensions", - "errorDetails": "Both width and height are required for rounded rectangle" + "errorDetails": "Both width and height are required for rounded rectangle", } width_nm = int(width * scale) @@ -87,26 +96,29 @@ class BoardOutlineCommands: center_x_nm = int(center_x * scale) center_y_nm = int(center_y * scale) corner_radius_nm = int(corner_radius * scale) - + # Create rounded rectangle self._add_rounded_rect( - center_x_nm, center_y_nm, - width_nm, height_nm, - corner_radius_nm, edge_layer + center_x_nm, + center_y_nm, + width_nm, + height_nm, + corner_radius_nm, + edge_layer, ) - + elif shape == "circle": if radius is None: return { "success": False, "message": "Missing radius", - "errorDetails": "Radius is required for circle" + "errorDetails": "Radius is required for circle", } center_x_nm = int(center_x * scale) center_y_nm = int(center_y * scale) radius_nm = int(radius * scale) - + # Create circle circle = pcbnew.PCB_SHAPE(self.board) circle.SetShape(pcbnew.SHAPE_T_CIRCLE) @@ -121,7 +133,7 @@ class BoardOutlineCommands: return { "success": False, "message": "Missing points", - "errorDetails": "At least 3 points are required for polygon" + "errorDetails": "At least 3 points are required for polygon", } # Convert points to nm @@ -130,13 +142,13 @@ class BoardOutlineCommands: x_nm = int(point["x"] * scale) y_nm = int(point["y"] * scale) polygon_points.append(pcbnew.VECTOR2I(x_nm, y_nm)) - + # Add lines for polygon for i in range(len(polygon_points)): self._add_edge_line( - polygon_points[i], - polygon_points[(i + 1) % len(polygon_points)], - edge_layer + polygon_points[i], + polygon_points[(i + 1) % len(polygon_points)], + edge_layer, ) return { @@ -149,8 +161,8 @@ class BoardOutlineCommands: "center": {"x": center_x, "y": center_y, "unit": unit}, "radius": radius, "cornerRadius": corner_radius, - "points": points - } + "points": points, + }, } except Exception as e: @@ -158,7 +170,7 @@ class BoardOutlineCommands: return { "success": False, "message": "Failed to add board outline", - "errorDetails": str(e) + "errorDetails": str(e), } def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -168,7 +180,7 @@ class BoardOutlineCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } position = params.get("position") @@ -180,19 +192,24 @@ class BoardOutlineCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "position and diameter are required" + "errorDetails": "position and diameter are required", } # Convert to internal units (nanometers) - scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm + scale = ( + 1000000 if position.get("unit", "mm") == "mm" else 25400000 + ) # mm or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) diameter_nm = int(diameter * scale) - pad_diameter_nm = int(pad_diameter * scale) if pad_diameter else diameter_nm + scale # 1mm larger by default + pad_diameter_nm = ( + int(pad_diameter * scale) if pad_diameter else diameter_nm + scale + ) # 1mm larger by default # Create footprint for mounting hole with unique reference existing_mh = [ - fp.GetReference() for fp in self.board.GetFootprints() + fp.GetReference() + for fp in self.board.GetFootprints() if fp.GetReference().startswith("MH") ] next_num = 1 @@ -202,20 +219,22 @@ class BoardOutlineCommands: module = pcbnew.FOOTPRINT(self.board) module.SetReference(f"MH{next_num}") module.SetValue(f"MountingHole_{diameter}mm") - + # Create the pad for the hole pad = pcbnew.PAD(module) pad.SetNumber(1) pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE) - pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH) + pad.SetAttribute( + pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH + ) pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm)) pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm)) pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module module.Add(pad) - + # Position the mounting hole module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) - + # Add to board self.board.Add(module) @@ -226,8 +245,8 @@ class BoardOutlineCommands: "position": position, "diameter": diameter, "padDiameter": pad_diameter or diameter + 1, - "plated": plated - } + "plated": plated, + }, } except Exception as e: @@ -235,7 +254,7 @@ class BoardOutlineCommands: return { "success": False, "message": "Failed to add mounting hole", - "errorDetails": str(e) + "errorDetails": str(e), } def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -245,7 +264,7 @@ class BoardOutlineCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } text = params.get("text") @@ -260,11 +279,13 @@ class BoardOutlineCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "text and position are required" + "errorDetails": "text and position are required", } # Convert to internal units (nanometers) - scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm + scale = ( + 1000000 if position.get("unit", "mm") == "mm" else 25400000 + ) # mm or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) size_nm = int(size * scale) @@ -276,7 +297,7 @@ class BoardOutlineCommands: return { "success": False, "message": "Invalid layer", - "errorDetails": f"Layer '{layer}' does not exist" + "errorDetails": f"Layer '{layer}' does not exist", } # Create text @@ -297,7 +318,7 @@ class BoardOutlineCommands: pcb_text.SetTextAngle(int(rotation * 10)) pcb_text.SetMirrored(mirror) - + # Add to board self.board.Add(pcb_text) @@ -311,8 +332,8 @@ class BoardOutlineCommands: "size": size, "thickness": thickness, "rotation": rotation, - "mirror": mirror - } + "mirror": mirror, + }, } except Exception as e: @@ -320,10 +341,12 @@ class BoardOutlineCommands: return { "success": False, "message": "Failed to add text", - "errorDetails": str(e) + "errorDetails": str(e), } - def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None: + def _add_edge_line( + self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int + ) -> None: """Add a line to the edge cuts layer""" line = pcbnew.PCB_SHAPE(self.board) line.SetShape(pcbnew.SHAPE_T_SEGMENT) @@ -333,96 +356,112 @@ class BoardOutlineCommands: line.SetWidth(0) # Zero width for edge cuts self.board.Add(line) - def _add_rounded_rect(self, center_x_nm: int, center_y_nm: int, - width_nm: int, height_nm: int, - radius_nm: int, layer: int) -> None: + def _add_rounded_rect( + self, + center_x_nm: int, + center_y_nm: int, + width_nm: int, + height_nm: int, + radius_nm: int, + layer: int, + ) -> None: """Add a rounded rectangle to the edge cuts layer""" if radius_nm <= 0: # If no radius, create regular rectangle - top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2) - top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2) - bottom_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm + height_nm // 2) - bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2) - + top_left = pcbnew.VECTOR2I( + center_x_nm - width_nm // 2, center_y_nm - height_nm // 2 + ) + top_right = pcbnew.VECTOR2I( + center_x_nm + width_nm // 2, center_y_nm - height_nm // 2 + ) + bottom_right = pcbnew.VECTOR2I( + center_x_nm + width_nm // 2, center_y_nm + height_nm // 2 + ) + bottom_left = pcbnew.VECTOR2I( + center_x_nm - width_nm // 2, center_y_nm + height_nm // 2 + ) + self._add_edge_line(top_left, top_right, layer) self._add_edge_line(top_right, bottom_right, layer) self._add_edge_line(bottom_right, bottom_left, layer) self._add_edge_line(bottom_left, top_left, layer) return - + # Calculate corner centers half_width = width_nm // 2 half_height = height_nm // 2 - + # Ensure radius is not larger than half the smallest dimension max_radius = min(half_width, half_height) if radius_nm > max_radius: radius_nm = max_radius - + # Calculate corner centers top_left_center = pcbnew.VECTOR2I( - center_x_nm - half_width + radius_nm, - center_y_nm - half_height + radius_nm + center_x_nm - half_width + radius_nm, center_y_nm - half_height + radius_nm ) top_right_center = pcbnew.VECTOR2I( - center_x_nm + half_width - radius_nm, - center_y_nm - half_height + radius_nm + center_x_nm + half_width - radius_nm, center_y_nm - half_height + radius_nm ) bottom_right_center = pcbnew.VECTOR2I( - center_x_nm + half_width - radius_nm, - center_y_nm + half_height - radius_nm + center_x_nm + half_width - radius_nm, center_y_nm + half_height - radius_nm ) bottom_left_center = pcbnew.VECTOR2I( - center_x_nm - half_width + radius_nm, - center_y_nm + half_height - radius_nm + center_x_nm - half_width + radius_nm, center_y_nm + half_height - radius_nm ) - + # Add arcs for corners self._add_corner_arc(top_left_center, radius_nm, 180, 270, layer) self._add_corner_arc(top_right_center, radius_nm, 270, 0, layer) self._add_corner_arc(bottom_right_center, radius_nm, 0, 90, layer) self._add_corner_arc(bottom_left_center, radius_nm, 90, 180, layer) - + # Add lines for straight edges # Top edge self._add_edge_line( pcbnew.VECTOR2I(top_left_center.x, top_left_center.y - radius_nm), pcbnew.VECTOR2I(top_right_center.x, top_right_center.y - radius_nm), - layer + layer, ) # Right edge self._add_edge_line( pcbnew.VECTOR2I(top_right_center.x + radius_nm, top_right_center.y), pcbnew.VECTOR2I(bottom_right_center.x + radius_nm, bottom_right_center.y), - layer + layer, ) # Bottom edge self._add_edge_line( pcbnew.VECTOR2I(bottom_right_center.x, bottom_right_center.y + radius_nm), pcbnew.VECTOR2I(bottom_left_center.x, bottom_left_center.y + radius_nm), - layer + layer, ) # Left edge self._add_edge_line( pcbnew.VECTOR2I(bottom_left_center.x - radius_nm, bottom_left_center.y), pcbnew.VECTOR2I(top_left_center.x - radius_nm, top_left_center.y), - layer + layer, ) - def _add_corner_arc(self, center: pcbnew.VECTOR2I, radius: int, - start_angle: float, end_angle: float, layer: int) -> None: + def _add_corner_arc( + self, + center: pcbnew.VECTOR2I, + radius: int, + start_angle: float, + end_angle: float, + layer: int, + ) -> None: """Add an arc for a rounded corner""" # Create arc for corner arc = pcbnew.PCB_SHAPE(self.board) arc.SetShape(pcbnew.SHAPE_T_ARC) arc.SetCenter(center) - + # Calculate start and end points start_x = center.x + int(radius * math.cos(math.radians(start_angle))) start_y = center.y + int(radius * math.sin(math.radians(start_angle))) end_x = center.x + int(radius * math.cos(math.radians(end_angle))) end_y = center.y + int(radius * math.sin(math.radians(end_angle))) - + arc.SetStart(pcbnew.VECTOR2I(start_x, start_y)) arc.SetEnd(pcbnew.VECTOR2I(end_x, end_y)) arc.SetLayer(layer) diff --git a/python/commands/export.py b/python/commands/export.py index d773431..5c74a66 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -8,7 +8,8 @@ import logging from typing import Dict, Any, Optional, List, Tuple import base64 -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class ExportCommands: """Handles export-related KiCAD operations""" @@ -24,7 +25,7 @@ class ExportCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } output_dir = params.get("outputDir") @@ -38,7 +39,7 @@ class ExportCommands: return { "success": False, "message": "Missing output directory", - "errorDetails": "outputDir parameter is required" + "errorDetails": "outputDir parameter is required", } # Create output directory if it doesn't exist @@ -47,7 +48,7 @@ class ExportCommands: # Create plot controller plotter = pcbnew.PLOT_CONTROLLER(self.board) - + # Set up plot options plot_opts = plotter.GetPlotOptions() plot_opts.SetOutputDirectory(output_dir) @@ -84,28 +85,40 @@ class ExportCommands: if kicad_cli and board_file and os.path.exists(board_file): import subprocess + # Generate drill files using kicad-cli cmd = [ kicad_cli, - 'pcb', 'export', 'drill', - '--output', output_dir, - '--format', 'excellon', - '--drill-origin', 'absolute', - '--excellon-separate-th', # Separate plated/non-plated - board_file + "pcb", + "export", + "drill", + "--output", + output_dir, + "--format", + "excellon", + "--drill-origin", + "absolute", + "--excellon-separate-th", # Separate plated/non-plated + board_file, ] try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + result = subprocess.run( + cmd, capture_output=True, text=True, timeout=60 + ) if result.returncode == 0: # Get list of generated drill files for file in os.listdir(output_dir): if file.endswith((".drl", ".cnc")): drill_files.append(file) else: - logger.warning(f"Drill file generation failed: {result.stderr}") + logger.warning( + f"Drill file generation failed: {result.stderr}" + ) except Exception as drill_error: - logger.warning(f"Could not generate drill files: {str(drill_error)}") + logger.warning( + f"Could not generate drill files: {str(drill_error)}" + ) else: logger.warning("kicad-cli not available for drill file generation") @@ -115,9 +128,9 @@ class ExportCommands: "files": { "gerber": plotted_layers, "drill": drill_files, - "map": ["job.gbrjob"] if generate_map_file else [] + "map": ["job.gbrjob"] if generate_map_file else [], }, - "outputDir": output_dir + "outputDir": output_dir, } except Exception as e: @@ -125,7 +138,7 @@ class ExportCommands: return { "success": False, "message": "Failed to export Gerber files", - "errorDetails": str(e) + "errorDetails": str(e), } def export_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -135,7 +148,7 @@ class ExportCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } output_path = params.get("outputPath") @@ -148,7 +161,7 @@ class ExportCommands: return { "success": False, "message": "Missing output path", - "errorDetails": "outputPath parameter is required" + "errorDetails": "outputPath parameter is required", } # Create output directory if it doesn't exist @@ -180,13 +193,15 @@ class ExportCommands: plot_opts.SetAutoScale(True) # Note: KiCAD 9.0 doesn't support explicit page size selection # for formats other than A4. The PDF will auto-scale to fit. - logger.warning(f"Page size '{page_size}' requested, but KiCAD 9.0 only supports A4 explicitly. Using auto-scale instead.") + logger.warning( + f"Page size '{page_size}' requested, but KiCAD 9.0 only supports A4 explicitly. Using auto-scale instead." + ) # Open plot for writing # Note: For PDF, all layers are combined into a single file # KiCAD prepends the board filename to the plot file name - base_name = os.path.basename(output_path).replace('.pdf', '') - plotter.OpenPlotfile(base_name, pcbnew.PLOT_FORMAT_PDF, '') + base_name = os.path.basename(output_path).replace(".pdf", "") + plotter.OpenPlotfile(base_name, pcbnew.PLOT_FORMAT_PDF, "") # Plot specified layers or all enabled layers plotted_layers = [] @@ -212,7 +227,9 @@ class ExportCommands: # Get the actual output filename that was created board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0] actual_filename = f"{board_name}-{base_name}.pdf" - actual_output_path = os.path.join(os.path.dirname(output_path), actual_filename) + actual_output_path = os.path.join( + os.path.dirname(output_path), actual_filename + ) return { "success": True, @@ -221,8 +238,8 @@ class ExportCommands: "path": actual_output_path, "requestedPath": output_path, "layers": plotted_layers, - "pageSize": page_size if page_size == "A4" else "auto-scaled" - } + "pageSize": page_size if page_size == "A4" else "auto-scaled", + }, } except Exception as e: @@ -230,7 +247,7 @@ class ExportCommands: return { "success": False, "message": "Failed to export PDF file", - "errorDetails": str(e) + "errorDetails": str(e), } def export_svg(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -240,7 +257,7 @@ class ExportCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } output_path = params.get("outputPath") @@ -252,7 +269,7 @@ class ExportCommands: return { "success": False, "message": "Missing output path", - "errorDetails": "outputPath parameter is required" + "errorDetails": "outputPath parameter is required", } # Create output directory if it doesn't exist @@ -261,7 +278,7 @@ class ExportCommands: # Create plot controller plotter = pcbnew.PLOT_CONTROLLER(self.board) - + # Set up plot options plot_opts = plotter.GetPlotOptions() plot_opts.SetOutputDirectory(os.path.dirname(output_path)) @@ -290,10 +307,7 @@ class ExportCommands: return { "success": True, "message": "Exported SVG file", - "file": { - "path": output_path, - "layers": plotted_layers - } + "file": {"path": output_path, "layers": plotted_layers}, } except Exception as e: @@ -301,7 +315,7 @@ class ExportCommands: return { "success": False, "message": "Failed to export SVG file", - "errorDetails": str(e) + "errorDetails": str(e), } def export_3d(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -315,7 +329,7 @@ class ExportCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } output_path = params.get("outputPath") @@ -329,7 +343,7 @@ class ExportCommands: return { "success": False, "message": "Missing output path", - "errorDetails": "outputPath parameter is required" + "errorDetails": "outputPath parameter is required", } # Get board file path @@ -338,7 +352,7 @@ class ExportCommands: return { "success": False, "message": "Board file not found", - "errorDetails": "Board must be saved before exporting 3D models" + "errorDetails": "Board must be saved before exporting 3D models", } # Create output directory if it doesn't exist @@ -351,7 +365,7 @@ class ExportCommands: return { "success": False, "message": "kicad-cli not found", - "errorDetails": "KiCAD CLI tool not found. Install KiCAD 8.0+ or set PATH." + "errorDetails": "KiCAD CLI tool not found. Install KiCAD 8.0+ or set PATH.", } # Build command based on format @@ -360,30 +374,39 @@ class ExportCommands: if format_upper == "STEP": cmd = [ kicad_cli, - 'pcb', 'export', 'step', - '--output', output_path, - '--force' # Overwrite existing file + "pcb", + "export", + "step", + "--output", + output_path, + "--force", # Overwrite existing file ] # Add options based on parameters if not include_components: - cmd.append('--no-components') + cmd.append("--no-components") if include_copper: - cmd.extend(['--include-tracks', '--include-pads', '--include-zones']) + cmd.extend( + ["--include-tracks", "--include-pads", "--include-zones"] + ) if include_silkscreen: - cmd.append('--include-silkscreen') + cmd.append("--include-silkscreen") if include_solder_mask: - cmd.append('--include-soldermask') + cmd.append("--include-soldermask") cmd.append(board_file) elif format_upper == "VRML": cmd = [ kicad_cli, - 'pcb', 'export', 'vrml', - '--output', output_path, - '--units', 'mm', # Use mm for consistency - '--force' + "pcb", + "export", + "vrml", + "--output", + output_path, + "--units", + "mm", # Use mm for consistency + "--force", ] if not include_components: @@ -397,7 +420,7 @@ class ExportCommands: return { "success": False, "message": "Unsupported format", - "errorDetails": f"Format {format} is not supported. Use 'STEP' or 'VRML'." + "errorDetails": f"Format {format} is not supported. Use 'STEP' or 'VRML'.", } # Execute kicad-cli command @@ -407,7 +430,7 @@ class ExportCommands: cmd, capture_output=True, text=True, - timeout=300 # 5 minute timeout for 3D export + timeout=300, # 5 minute timeout for 3D export ) if result.returncode != 0: @@ -415,16 +438,13 @@ class ExportCommands: return { "success": False, "message": "3D export command failed", - "errorDetails": result.stderr + "errorDetails": result.stderr, } return { "success": True, "message": f"Exported {format_upper} file", - "file": { - "path": output_path, - "format": format_upper - } + "file": {"path": output_path, "format": format_upper}, } except subprocess.TimeoutExpired: @@ -432,14 +452,14 @@ class ExportCommands: return { "success": False, "message": "3D export timed out", - "errorDetails": "Export took longer than 5 minutes" + "errorDetails": "Export took longer than 5 minutes", } except Exception as e: logger.error(f"Error exporting 3D model: {str(e)}") return { "success": False, "message": "Failed to export 3D model", - "errorDetails": str(e) + "errorDetails": str(e), } def export_bom(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -449,7 +469,7 @@ class ExportCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } output_path = params.get("outputPath") @@ -461,7 +481,7 @@ class ExportCommands: return { "success": False, "message": "Missing output path", - "errorDetails": "outputPath parameter is required" + "errorDetails": "outputPath parameter is required", } # Create output directory if it doesn't exist @@ -475,7 +495,7 @@ class ExportCommands: "reference": module.GetReference(), "value": module.GetValue(), "footprint": module.GetFPID().GetUniStringLibId(), - "layer": self.board.GetLayerName(module.GetLayer()) + "layer": self.board.GetLayerName(module.GetLayer()), } # Add requested attributes @@ -495,7 +515,7 @@ class ExportCommands: "value": comp["value"], "footprint": comp["footprint"], "quantity": 1, - "references": [comp["reference"]] + "references": [comp["reference"]], } else: grouped[key]["quantity"] += 1 @@ -515,7 +535,7 @@ class ExportCommands: return { "success": False, "message": "Unsupported format", - "errorDetails": f"Format {format} is not supported" + "errorDetails": f"Format {format} is not supported", } return { @@ -524,8 +544,8 @@ class ExportCommands: "file": { "path": output_path, "format": format, - "componentCount": len(components) - } + "componentCount": len(components), + }, } except Exception as e: @@ -533,13 +553,14 @@ class ExportCommands: return { "success": False, "message": "Failed to export BOM", - "errorDetails": str(e) + "errorDetails": str(e), } def _export_bom_csv(self, path: str, components: List[Dict[str, Any]]) -> None: """Export BOM to CSV format""" import csv - with open(path, 'w', newline='') as f: + + with open(path, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=components[0].keys()) writer.writeheader() writer.writerows(components) @@ -547,6 +568,7 @@ class ExportCommands: def _export_bom_xml(self, path: str, components: List[Dict[str, Any]]) -> None: """Export BOM to XML format""" import xml.etree.ElementTree as ET + root = ET.Element("bom") for comp in components: comp_elem = ET.SubElement(root, "component") @@ -554,7 +576,7 @@ class ExportCommands: elem = ET.SubElement(comp_elem, key) elem.text = str(value) tree = ET.ElementTree(root) - tree.write(path, encoding='utf-8', xml_declaration=True) + tree.write(path, encoding="utf-8", xml_declaration=True) def _export_bom_html(self, path: str, components: List[Dict[str, Any]]) -> None: """Export BOM to HTML format""" @@ -571,13 +593,14 @@ class ExportCommands: html.append(f"{value}") html.append("") html.append("") - with open(path, 'w') as f: + with open(path, "w") as f: f.write("\n".join(html)) def _export_bom_json(self, path: str, components: List[Dict[str, Any]]) -> None: """Export BOM to JSON format""" import json - with open(path, 'w') as f: + + with open(path, "w") as f: json.dump({"components": components}, f, indent=2) def _find_kicad_cli(self) -> Optional[str]: diff --git a/python/commands/routing.py b/python/commands/routing.py index 2aa366a..b84f4c1 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -145,18 +145,40 @@ class RoutingCommands: net = start_pad.GetNetname() or end_pad.GetNetname() or "" # Delegate to route_trace - result = self.route_trace({ - "start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"}, - "end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"}, - "layer": layer, - "width": width, - "net": net, - }) + result = self.route_trace( + { + "start": { + "x": start_pos.x / scale, + "y": start_pos.y / scale, + "unit": "mm", + }, + "end": { + "x": end_pos.x / scale, + "y": end_pos.y / scale, + "unit": "mm", + }, + "layer": layer, + "width": width, + "net": net, + } + ) if result.get("success"): - result["message"] = f"Routed {from_ref}.{from_pad} → {to_ref}.{to_pad} (net: {net or 'none'})" - result["fromPad"] = {"ref": from_ref, "pad": from_pad, "x": start_pos.x / scale, "y": start_pos.y / scale} - result["toPad"] = {"ref": to_ref, "pad": to_pad, "x": end_pos.x / scale, "y": end_pos.y / scale} + result["message"] = ( + f"Routed {from_ref}.{from_pad} → {to_ref}.{to_pad} (net: {net or 'none'})" + ) + result["fromPad"] = { + "ref": from_ref, + "pad": from_pad, + "x": start_pos.x / scale, + "y": start_pos.y / scale, + } + result["toPad"] = { + "ref": to_ref, + "pad": to_pad, + "x": end_pos.x / scale, + "y": end_pos.y / scale, + } return result @@ -886,7 +908,9 @@ class RoutingCommands: else: traces_to_copy.append(track) - filter_method = "net-based" if use_net_filter else "geometric (pads have no nets)" + filter_method = ( + "net-based" if use_net_filter else "geometric (pads have no nets)" + ) logger.info( f"copy_routing_pattern: {len(traces_to_copy)} traces, " f"{len(vias_to_copy)} vias selected via {filter_method}" diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 94b63af..891a6d1 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -592,7 +592,9 @@ class KiCADInterface: self._current_project_path = project_path local_lib = FootprintLibraryManager(project_path=project_path) self.component_commands = ComponentCommands(self.board, local_lib) - logger.info(f"Reloaded FootprintLibraryManager with project_path={project_path}") + logger.info( + f"Reloaded FootprintLibraryManager with project_path={project_path}" + ) return self.component_commands.place_component(params) @@ -665,7 +667,10 @@ class KiCADInterface: sch_file = Path(schematic_path) if not sch_file.exists(): - return {"success": False, "message": f"Schematic not found: {schematic_path}"} + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } with open(sch_file, "r", encoding="utf-8") as f: lines = f.read().split("\n") @@ -676,9 +681,13 @@ class KiCADInterface: for i, line in enumerate(lines): if "(lib_symbols" in line and lib_sym_start is None: lib_sym_start = i - depth = sum(1 for c in line if c == "(") - sum(1 for c in line if c == ")") + depth = sum(1 for c in line if c == "(") - sum( + 1 for c in line if c == ")" + ) elif lib_sym_start is not None and lib_sym_end is None: - depth += sum(1 for c in line if c == "(") - sum(1 for c in line if c == ")") + depth += sum(1 for c in line if c == "(") - sum( + 1 for c in line if c == ")" + ) if depth == 0: lib_sym_end = i break @@ -695,15 +704,22 @@ class KiCADInterface: if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", lines[i]): b_start = i - b_depth = sum(1 for c in lines[i] if c == "(") - sum(1 for c in lines[i] if c == ")") + b_depth = sum(1 for c in lines[i] if c == "(") - sum( + 1 for c in lines[i] if c == ")" + ) j = i + 1 while j < len(lines) and b_depth > 0: - b_depth += sum(1 for c in lines[j] if c == "(") - sum(1 for c in lines[j] if c == ")") + b_depth += sum(1 for c in lines[j] if c == "(") - sum( + 1 for c in lines[j] if c == ")" + ) j += 1 b_end = j - 1 - block_text = "\n".join(lines[b_start:b_end + 1]) - if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text): + block_text = "\n".join(lines[b_start : b_end + 1]) + if re.search( + r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', + block_text, + ): blocks_to_delete.append((b_start, b_end)) i = b_end + 1 @@ -719,7 +735,7 @@ class KiCADInterface: # Delete from back to front to preserve line indices for b_start, b_end in sorted(blocks_to_delete, reverse=True): - del lines[b_start:b_end + 1] + del lines[b_start : b_end + 1] if b_start < len(lines) and lines[b_start].strip() == "": del lines[b_start] @@ -727,18 +743,27 @@ class KiCADInterface: f.write("\n".join(lines)) deleted_count = len(blocks_to_delete) - logger.info(f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}") - return {"success": True, "reference": reference, "deleted_count": deleted_count, "schematic": str(sch_file)} + logger.info( + f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}" + ) + return { + "success": True, + "reference": reference, + "deleted_count": deleted_count, + "schematic": str(sch_file), + } except Exception as e: logger.error(f"Error deleting schematic component: {e}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} def _handle_edit_schematic_component(self, params): """Update properties of a placed symbol in a schematic (footprint, value, reference). - Uses text-based in-place editing – preserves position, UUID and all other fields.""" + Uses text-based in-place editing – preserves position, UUID and all other fields. + """ logger.info("Editing schematic component") try: from pathlib import Path @@ -754,12 +779,24 @@ class KiCADInterface: return {"success": False, "message": "schematicPath is required"} if not reference: return {"success": False, "message": "reference is required"} - if not any([new_footprint is not None, new_value is not None, new_reference is not None]): - return {"success": False, "message": "At least one of footprint, value, or newReference must be provided"} + if not any( + [ + new_footprint is not None, + new_value is not None, + new_reference is not None, + ] + ): + return { + "success": False, + "message": "At least one of footprint, value, or newReference must be provided", + } sch_file = Path(schematic_path) if not sch_file.exists(): - return {"success": False, "message": f"Schematic not found: {schematic_path}"} + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } with open(sch_file, "r", encoding="utf-8") as f: content = f.read() @@ -769,9 +806,9 @@ class KiCADInterface: depth = 0 i = start while i < len(s): - if s[i] == '(': + if s[i] == "(": depth += 1 - elif s[i] == ')': + elif s[i] == ")": depth -= 1 if depth == 0: return i @@ -780,7 +817,9 @@ class KiCADInterface: # Skip lib_symbols section lib_sym_pos = content.find("(lib_symbols") - lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 + lib_sym_end = ( + find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 + ) # Find placed symbol blocks that match the reference # Search for (symbol (lib_id "...") ... (property "Reference" "" ...) ...) @@ -800,36 +839,61 @@ class KiCADInterface: if end < 0: search_start = pos + 1 continue - block_text = content[pos:end + 1] - if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text): + block_text = content[pos : end + 1] + if re.search( + r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', + block_text, + ): block_start, block_end = pos, end break search_start = end + 1 if block_start is None: - return {"success": False, "message": f"Component '{reference}' not found in schematic"} + return { + "success": False, + "message": f"Component '{reference}' not found in schematic", + } # Apply property replacements within the found block - block_text = content[block_start:block_end + 1] + block_text = content[block_start : block_end + 1] if new_footprint is not None: - block_text = re.sub(r'(\(property\s+"Footprint"\s+)"[^"]*"', rf'\1"{new_footprint}"', block_text) + block_text = re.sub( + r'(\(property\s+"Footprint"\s+)"[^"]*"', + rf'\1"{new_footprint}"', + block_text, + ) if new_value is not None: - block_text = re.sub(r'(\(property\s+"Value"\s+)"[^"]*"', rf'\1"{new_value}"', block_text) + block_text = re.sub( + r'(\(property\s+"Value"\s+)"[^"]*"', rf'\1"{new_value}"', block_text + ) if new_reference is not None: - block_text = re.sub(r'(\(property\s+"Reference"\s+)"[^"]*"', rf'\1"{new_reference}"', block_text) + block_text = re.sub( + r'(\(property\s+"Reference"\s+)"[^"]*"', + rf'\1"{new_reference}"', + block_text, + ) - content = content[:block_start] + block_text + content[block_end + 1:] + content = content[:block_start] + block_text + content[block_end + 1 :] with open(sch_file, "w", encoding="utf-8") as f: f.write(content) - changes = {k: v for k, v in {"footprint": new_footprint, "value": new_value, "reference": new_reference}.items() if v is not None} + changes = { + k: v + for k, v in { + "footprint": new_footprint, + "value": new_value, + "reference": new_reference, + }.items() + if v is not None + } logger.info(f"Edited schematic component {reference}: {changes}") return {"success": True, "reference": reference, "updated": changes} except Exception as e: logger.error(f"Error editing schematic component: {e}") import traceback + logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} @@ -899,7 +963,9 @@ class KiCADInterface: def _handle_create_footprint(self, params): """Create a new .kicad_mod footprint file in a .pretty library.""" - logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}") + logger.info( + f"create_footprint: {params.get('name')} in {params.get('libraryPath')}" + ) try: creator = FootprintCreator() return creator.create_footprint( @@ -921,7 +987,9 @@ class KiCADInterface: def _handle_edit_footprint_pad(self, params): """Edit an existing pad in a .kicad_mod file.""" - logger.info(f"edit_footprint_pad: pad {params.get('padNumber')} in {params.get('footprintPath')}") + logger.info( + f"edit_footprint_pad: pad {params.get('padNumber')} in {params.get('footprintPath')}" + ) try: creator = FootprintCreator() return creator.edit_footprint_pad( @@ -970,7 +1038,9 @@ class KiCADInterface: def _handle_create_symbol(self, params): """Create a new symbol in a .kicad_sym library.""" - logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}") + logger.info( + f"create_symbol: {params.get('name')} in {params.get('libraryPath')}" + ) try: creator = SymbolCreator() return creator.create_symbol( @@ -994,7 +1064,9 @@ class KiCADInterface: def _handle_delete_symbol(self, params): """Delete a symbol from a .kicad_sym library.""" - logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}") + logger.info( + f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}" + ) try: creator = SymbolCreator() return creator.delete_symbol( @@ -2210,7 +2282,10 @@ class KiCADInterface: return manager.enrich_schematic(Path(schematic_path), dry_run=dry_run) except Exception as e: logger.error(f"Error enriching datasheets: {e}", exc_info=True) - return {"success": False, "message": f"Failed to enrich datasheets: {str(e)}"} + return { + "success": False, + "message": f"Failed to enrich datasheets: {str(e)}", + } def _handle_get_datasheet_url(self, params): """Return LCSC datasheet and product URLs for a part number""" @@ -2232,7 +2307,10 @@ class KiCADInterface: } except Exception as e: logger.error(f"Error getting datasheet URL: {e}", exc_info=True) - return {"success": False, "message": f"Failed to get datasheet URL: {str(e)}"} + return { + "success": False, + "message": f"Failed to get datasheet URL: {str(e)}", + } def main():