From bfc25639c2a672148b93dd8a979b56f2805c1902 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sat, 18 Apr 2026 15:23:00 +0100 Subject: [PATCH] chore: normalize all tracked files to LF line endings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mechanical application of the `.gitattributes` rules from the prior commit. All 50 files differ only in line endings — verified by `git diff --cached --ignore-all-space` being empty. Before: main had 42 CRLF + 27 LF Python files plus mixed-ending in YAML, templates, and shell scripts. After: every text file is LF (except the Windows-native *.ps1, *.bat scripts which remain CRLF per gitattributes). This eliminates the noisy-diff failure mode seen in PR #102, where a small logic change produced a 918-line diff due to whole-file CRLF→LF conversion. --- .gitignore | 188 +- pytest.ini | 104 +- python/commands/__init__.py | 38 +- python/commands/board.py | 22 +- python/commands/board/__init__.py | 170 +- python/commands/board/layers.py | 352 +- python/commands/board/outline.py | 968 ++-- python/commands/board/size.py | 140 +- python/commands/board/view.py | 452 +- python/commands/component.py | 2424 +++++----- python/commands/component_schematic.py | 820 ++-- python/commands/design_rules.py | 918 ++-- python/commands/export.py | 1394 +++--- python/commands/jlcpcb.py | 606 +-- python/commands/jlcpcb_parts.py | 1002 ++--- python/commands/jlcsearch.py | 480 +- python/commands/library.py | 1110 ++--- python/commands/library_schematic.py | 260 +- python/commands/pin_locator.py | 1014 ++--- python/commands/project.py | 510 +-- python/commands/routing.py | 2862 ++++++------ python/commands/schematic_analysis.py | 1952 ++++---- python/commands/wire_connectivity.py | 988 ++-- python/commands/wire_dragger.py | 878 ++-- python/commands/wire_manager.py | 1782 ++++---- python/kicad_api/__init__.py | 54 +- python/kicad_api/base.py | 586 +-- python/kicad_api/factory.py | 390 +- python/kicad_api/ipc_backend.py | 2468 +++++----- python/kicad_api/swig_backend.py | 436 +- python/parsers/__init__.py | 2 +- python/parsers/kicad_mod_parser.py | 500 +-- python/requirements.txt | 26 +- python/resources/__init__.py | 14 +- python/resources/resource_definitions.py | 684 +-- python/schemas/__init__.py | 14 +- python/schemas/tool_schemas.py | 3960 ++++++++--------- python/templates/empty.kicad_sch | 274 +- .../templates/template_with_symbols.kicad_sch | 388 +- .../template_with_symbols_expanded.kicad_sch | 1468 +++--- python/test_ipc_backend.py | 638 +-- python/utils/__init__.py | 2 +- python/utils/kicad_process.py | 698 +-- python/utils/platform_helper.py | 650 +-- requirements-dev.txt | 58 +- requirements.txt | 52 +- scripts/install-linux.sh | 330 +- setup-windows.ps1 | 820 ++-- tests/__init__.py | 2 +- tests/test_platform_helper.py | 386 +- 50 files changed, 18167 insertions(+), 18167 deletions(-) diff --git a/.gitignore b/.gitignore index e5dfd7c..f326f72 100644 --- a/.gitignore +++ b/.gitignore @@ -1,94 +1,94 @@ -# Node.js -node_modules/ -npm-debug.log* -yarn-debug.log* -yarn-error.log* -dist/ -.npm -.eslintcache - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST -.pytest_cache/ -.coverage -htmlcov/ -.tox/ -.hypothesis/ -*.cover -.mypy_cache/ -.dmypy.json -dmypy.json - -# Virtual Environments -venv/ -.venv/ -env/ -ENV/ - -# IDEs -.vscode/ -.idea/ -*.swp -*.swo -*~ -.DS_Store - -# Logs -logs/ -*.log -~/.kicad-mcp/ - -# Environment -.env -.env.local -.env.*.local - -# KiCAD -*.kicad_pcb-bak -*.kicad_pcb.bak -*.kicad_sch-bak -*.kicad_sch.bak -*.kicad_pro-bak -*.kicad_pro.bak -*.kicad_prl -*-backups/ -fp-info-cache - -# Testing -test_output/ -schematic_test_output/ -coverage.xml -.coverage.* - -# Data & Databases -data/ -*.db -*.db-journal - -# OS -Thumbs.db -Desktop.ini - -# Generated local config files (contain machine-specific paths) -windows-mcp-config.json - -# Personal notes / local contributions (not for upstream) -myContribution/ +# Node.js +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +dist/ +.npm +.eslintcache + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.hypothesis/ +*.cover +.mypy_cache/ +.dmypy.json +dmypy.json + +# Virtual Environments +venv/ +.venv/ +env/ +ENV/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +logs/ +*.log +~/.kicad-mcp/ + +# Environment +.env +.env.local +.env.*.local + +# KiCAD +*.kicad_pcb-bak +*.kicad_pcb.bak +*.kicad_sch-bak +*.kicad_sch.bak +*.kicad_pro-bak +*.kicad_pro.bak +*.kicad_prl +*-backups/ +fp-info-cache + +# Testing +test_output/ +schematic_test_output/ +coverage.xml +.coverage.* + +# Data & Databases +data/ +*.db +*.db-journal + +# OS +Thumbs.db +Desktop.ini + +# Generated local config files (contain machine-specific paths) +windows-mcp-config.json + +# Personal notes / local contributions (not for upstream) +myContribution/ diff --git a/pytest.ini b/pytest.ini index 9424546..bc8c8d9 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,52 +1,52 @@ -[pytest] -# Pytest configuration for KiCAD MCP Server - -# Test discovery patterns -python_files = test_*.py *_test.py -python_classes = Test* -python_functions = test_* - -# Test paths -testpaths = tests - -# Minimum Python version -minversion = 6.0 - -# Additional options -addopts = - -ra - --strict-markers - --strict-config - --showlocals - --tb=short - --cov=python - --cov-report=term-missing - --cov-report=html - --cov-report=xml - --cov-branch - -# Markers for organizing tests -markers = - unit: Unit tests (fast, no external dependencies) - integration: Integration tests (requires KiCAD) - slow: Slow-running tests - linux: Linux-specific tests - windows: Windows-specific tests - macos: macOS-specific tests - -# Ignore patterns -norecursedirs = .git .tox dist build *.egg node_modules - -# Coverage settings -[coverage:run] -source = python -omit = - */tests/* - */test_*.py - */__pycache__/* - */site-packages/* - -[coverage:report] -precision = 2 -show_missing = True -skip_covered = False +[pytest] +# Pytest configuration for KiCAD MCP Server + +# Test discovery patterns +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# Test paths +testpaths = tests + +# Minimum Python version +minversion = 6.0 + +# Additional options +addopts = + -ra + --strict-markers + --strict-config + --showlocals + --tb=short + --cov=python + --cov-report=term-missing + --cov-report=html + --cov-report=xml + --cov-branch + +# Markers for organizing tests +markers = + unit: Unit tests (fast, no external dependencies) + integration: Integration tests (requires KiCAD) + slow: Slow-running tests + linux: Linux-specific tests + windows: Windows-specific tests + macos: macOS-specific tests + +# Ignore patterns +norecursedirs = .git .tox dist build *.egg node_modules + +# Coverage settings +[coverage:run] +source = python +omit = + */tests/* + */test_*.py + */__pycache__/* + */site-packages/* + +[coverage:report] +precision = 2 +show_missing = True +skip_covered = False diff --git a/python/commands/__init__.py b/python/commands/__init__.py index 158d4cc..7f1931f 100644 --- a/python/commands/__init__.py +++ b/python/commands/__init__.py @@ -1,19 +1,19 @@ -""" -KiCAD command implementations package -""" - -from .board import BoardCommands -from .component import ComponentCommands -from .design_rules import DesignRuleCommands -from .export import ExportCommands -from .project import ProjectCommands -from .routing import RoutingCommands - -__all__ = [ - "ProjectCommands", - "BoardCommands", - "ComponentCommands", - "RoutingCommands", - "DesignRuleCommands", - "ExportCommands", -] +""" +KiCAD command implementations package +""" + +from .board import BoardCommands +from .component import ComponentCommands +from .design_rules import DesignRuleCommands +from .export import ExportCommands +from .project import ProjectCommands +from .routing import RoutingCommands + +__all__ = [ + "ProjectCommands", + "BoardCommands", + "ComponentCommands", + "RoutingCommands", + "DesignRuleCommands", + "ExportCommands", +] diff --git a/python/commands/board.py b/python/commands/board.py index 26109f6..60ba8d5 100644 --- a/python/commands/board.py +++ b/python/commands/board.py @@ -1,11 +1,11 @@ -""" -Board-related command implementations for KiCAD interface - -This file is maintained for backward compatibility. -It imports and re-exports the BoardCommands class from the board package. -""" - -from commands.board import BoardCommands - -# Re-export the BoardCommands class for backward compatibility -__all__ = ["BoardCommands"] +""" +Board-related command implementations for KiCAD interface + +This file is maintained for backward compatibility. +It imports and re-exports the BoardCommands class from the board package. +""" + +from commands.board import BoardCommands + +# Re-export the BoardCommands class for backward compatibility +__all__ = ["BoardCommands"] diff --git a/python/commands/board/__init__.py b/python/commands/board/__init__.py index 96e511c..ecab720 100644 --- a/python/commands/board/__init__.py +++ b/python/commands/board/__init__.py @@ -1,85 +1,85 @@ -""" -Board-related command implementations for KiCAD interface -""" - -import logging -from typing import Any, Dict, Optional - -import pcbnew - -from .layers import BoardLayerCommands -from .outline import BoardOutlineCommands - -# Import specialized modules -from .size import BoardSizeCommands -from .view import BoardViewCommands - -logger = logging.getLogger("kicad_interface") - - -class BoardCommands: - """Handles board-related KiCAD operations""" - - def __init__(self, board: Optional[pcbnew.BOARD] = None): - """Initialize with optional board instance""" - self.board = board - - # Initialize specialized command classes - self.size_commands = BoardSizeCommands(board) - self.layer_commands = BoardLayerCommands(board) - self.outline_commands = BoardOutlineCommands(board) - self.view_commands = BoardViewCommands(board) - - # Delegate board size commands - def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Set the size of the PCB board""" - self.size_commands.board = self.board - return self.size_commands.set_board_size(params) - - # Delegate layer commands - def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a new layer to the PCB""" - self.layer_commands.board = self.board - return self.layer_commands.add_layer(params) - - def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Set the active layer for PCB operations""" - self.layer_commands.board = self.board - return self.layer_commands.set_active_layer(params) - - def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get a list of all layers in the PCB""" - self.layer_commands.board = self.board - return self.layer_commands.get_layer_list(params) - - # Delegate board outline commands - def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a board outline to the PCB""" - self.outline_commands.board = self.board - return self.outline_commands.add_board_outline(params) - - def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a mounting hole to the PCB""" - self.outline_commands.board = self.board - return self.outline_commands.add_mounting_hole(params) - - def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add text annotation to the PCB""" - self.outline_commands.board = self.board - return self.outline_commands.add_text(params) - - # Delegate view commands - def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get information about the current board""" - self.view_commands.board = self.board - return self.view_commands.get_board_info(params) - - def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get a 2D image of the PCB""" - self.view_commands.board = self.board - return self.view_commands.get_board_2d_view(params) - - def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get the bounding box extents of the board""" - self.view_commands.board = self.board - return self.view_commands.get_board_extents(params) +""" +Board-related command implementations for KiCAD interface +""" + +import logging +from typing import Any, Dict, Optional + +import pcbnew + +from .layers import BoardLayerCommands +from .outline import BoardOutlineCommands + +# Import specialized modules +from .size import BoardSizeCommands +from .view import BoardViewCommands + +logger = logging.getLogger("kicad_interface") + + +class BoardCommands: + """Handles board-related KiCAD operations""" + + def __init__(self, board: Optional[pcbnew.BOARD] = None): + """Initialize with optional board instance""" + self.board = board + + # Initialize specialized command classes + self.size_commands = BoardSizeCommands(board) + self.layer_commands = BoardLayerCommands(board) + self.outline_commands = BoardOutlineCommands(board) + self.view_commands = BoardViewCommands(board) + + # Delegate board size commands + def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Set the size of the PCB board""" + self.size_commands.board = self.board + return self.size_commands.set_board_size(params) + + # Delegate layer commands + def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a new layer to the PCB""" + self.layer_commands.board = self.board + return self.layer_commands.add_layer(params) + + def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Set the active layer for PCB operations""" + self.layer_commands.board = self.board + return self.layer_commands.set_active_layer(params) + + def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get a list of all layers in the PCB""" + self.layer_commands.board = self.board + return self.layer_commands.get_layer_list(params) + + # Delegate board outline commands + def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a board outline to the PCB""" + self.outline_commands.board = self.board + return self.outline_commands.add_board_outline(params) + + def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a mounting hole to the PCB""" + self.outline_commands.board = self.board + return self.outline_commands.add_mounting_hole(params) + + def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add text annotation to the PCB""" + self.outline_commands.board = self.board + return self.outline_commands.add_text(params) + + # Delegate view commands + def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get information about the current board""" + self.view_commands.board = self.board + return self.view_commands.get_board_info(params) + + def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get a 2D image of the PCB""" + self.view_commands.board = self.board + return self.view_commands.get_board_2d_view(params) + + def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get the bounding box extents of the board""" + self.view_commands.board = self.board + return self.view_commands.get_board_extents(params) diff --git a/python/commands/board/layers.py b/python/commands/board/layers.py index aa56cdf..6debb14 100644 --- a/python/commands/board/layers.py +++ b/python/commands/board/layers.py @@ -1,176 +1,176 @@ -""" -Board layer command implementations for KiCAD interface -""" - -import logging -from typing import Any, Dict, Optional - -import pcbnew - -logger = logging.getLogger("kicad_interface") - - -class BoardLayerCommands: - """Handles board layer operations""" - - def __init__(self, board: Optional[pcbnew.BOARD] = None): - """Initialize with optional board instance""" - self.board = board - - def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a new layer to the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - name = params.get("name") - layer_type = params.get("type") - position = params.get("position") - number = params.get("number") - - if not name or not layer_type or not position: - return { - "success": False, - "message": "Missing parameters", - "errorDetails": "name, type, and position are required", - } - - # Get layer stack - layer_stack = self.board.GetLayerStack() - - # Determine layer ID based on position and number - layer_id = None - if position == "inner": - if number is None: - return { - "success": False, - "message": "Missing layer number", - "errorDetails": "number is required for inner layers", - } - layer_id = pcbnew.In1_Cu + (number - 1) - elif position == "top": - layer_id = pcbnew.F_Cu - elif position == "bottom": - layer_id = pcbnew.B_Cu - - if layer_id is None: - return { - "success": False, - "message": "Invalid layer position", - "errorDetails": "position must be 'top', 'bottom', or 'inner'", - } - - # Set layer properties - layer_stack.SetLayerName(layer_id, name) - layer_stack.SetLayerType(layer_id, self._get_layer_type(layer_type)) - - # Enable the layer - self.board.SetLayerEnabled(layer_id, True) - - return { - "success": True, - "message": f"Added layer: {name}", - "layer": {"name": name, "type": layer_type, "position": position, "number": number}, - } - - except Exception as e: - logger.error(f"Error adding layer: {str(e)}") - return {"success": False, "message": "Failed to add layer", "errorDetails": str(e)} - - def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Set the active layer for PCB operations""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - layer = params.get("layer") - if not layer: - return { - "success": False, - "message": "No layer specified", - "errorDetails": "layer parameter is required", - } - - # Find layer ID by name - layer_id = self.board.GetLayerID(layer) - if layer_id < 0: - return { - "success": False, - "message": "Layer not found", - "errorDetails": f"Layer '{layer}' does not exist", - } - - # Set active layer - self.board.SetActiveLayer(layer_id) - - return { - "success": True, - "message": f"Set active layer to: {layer}", - "layer": {"name": layer, "id": layer_id}, - } - - except Exception as e: - logger.error(f"Error setting active layer: {str(e)}") - return { - "success": False, - "message": "Failed to set active layer", - "errorDetails": str(e), - } - - def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get a list of all layers in the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - layers = [] - for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): - if self.board.IsLayerEnabled(layer_id): - layers.append( - { - "name": self.board.GetLayerName(layer_id), - "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), - "id": layer_id, - # Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0 - # Active layer is a UI concept not applicable to headless scripting - } - ) - - return {"success": True, "layers": layers} - - except Exception as e: - logger.error(f"Error getting layer list: {str(e)}") - return {"success": False, "message": "Failed to get layer list", "errorDetails": str(e)} - - def _get_layer_type(self, type_name: str) -> int: - """Convert layer type name to KiCAD layer type constant""" - type_map = { - "copper": pcbnew.LT_SIGNAL, - "technical": pcbnew.LT_SIGNAL, - "user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead - "signal": pcbnew.LT_SIGNAL, - } - return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL) - - def _get_layer_type_name(self, type_id: int) -> str: - """Convert KiCAD layer type constant to name""" - type_map = { - pcbnew.LT_SIGNAL: "signal", - pcbnew.LT_POWER: "power", - pcbnew.LT_MIXED: "mixed", - pcbnew.LT_JUMPER: "jumper", - } - # Note: LT_USER was removed in KiCAD 9.0 - return type_map.get(type_id, "unknown") +""" +Board layer command implementations for KiCAD interface +""" + +import logging +from typing import Any, Dict, Optional + +import pcbnew + +logger = logging.getLogger("kicad_interface") + + +class BoardLayerCommands: + """Handles board layer operations""" + + def __init__(self, board: Optional[pcbnew.BOARD] = None): + """Initialize with optional board instance""" + self.board = board + + def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a new layer to the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + name = params.get("name") + layer_type = params.get("type") + position = params.get("position") + number = params.get("number") + + if not name or not layer_type or not position: + return { + "success": False, + "message": "Missing parameters", + "errorDetails": "name, type, and position are required", + } + + # Get layer stack + layer_stack = self.board.GetLayerStack() + + # Determine layer ID based on position and number + layer_id = None + if position == "inner": + if number is None: + return { + "success": False, + "message": "Missing layer number", + "errorDetails": "number is required for inner layers", + } + layer_id = pcbnew.In1_Cu + (number - 1) + elif position == "top": + layer_id = pcbnew.F_Cu + elif position == "bottom": + layer_id = pcbnew.B_Cu + + if layer_id is None: + return { + "success": False, + "message": "Invalid layer position", + "errorDetails": "position must be 'top', 'bottom', or 'inner'", + } + + # Set layer properties + layer_stack.SetLayerName(layer_id, name) + layer_stack.SetLayerType(layer_id, self._get_layer_type(layer_type)) + + # Enable the layer + self.board.SetLayerEnabled(layer_id, True) + + return { + "success": True, + "message": f"Added layer: {name}", + "layer": {"name": name, "type": layer_type, "position": position, "number": number}, + } + + except Exception as e: + logger.error(f"Error adding layer: {str(e)}") + return {"success": False, "message": "Failed to add layer", "errorDetails": str(e)} + + def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Set the active layer for PCB operations""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + layer = params.get("layer") + if not layer: + return { + "success": False, + "message": "No layer specified", + "errorDetails": "layer parameter is required", + } + + # Find layer ID by name + layer_id = self.board.GetLayerID(layer) + if layer_id < 0: + return { + "success": False, + "message": "Layer not found", + "errorDetails": f"Layer '{layer}' does not exist", + } + + # Set active layer + self.board.SetActiveLayer(layer_id) + + return { + "success": True, + "message": f"Set active layer to: {layer}", + "layer": {"name": layer, "id": layer_id}, + } + + except Exception as e: + logger.error(f"Error setting active layer: {str(e)}") + return { + "success": False, + "message": "Failed to set active layer", + "errorDetails": str(e), + } + + def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get a list of all layers in the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + layers = [] + for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): + if self.board.IsLayerEnabled(layer_id): + layers.append( + { + "name": self.board.GetLayerName(layer_id), + "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), + "id": layer_id, + # Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0 + # Active layer is a UI concept not applicable to headless scripting + } + ) + + return {"success": True, "layers": layers} + + except Exception as e: + logger.error(f"Error getting layer list: {str(e)}") + return {"success": False, "message": "Failed to get layer list", "errorDetails": str(e)} + + def _get_layer_type(self, type_name: str) -> int: + """Convert layer type name to KiCAD layer type constant""" + type_map = { + "copper": pcbnew.LT_SIGNAL, + "technical": pcbnew.LT_SIGNAL, + "user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead + "signal": pcbnew.LT_SIGNAL, + } + return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL) + + def _get_layer_type_name(self, type_id: int) -> str: + """Convert KiCAD layer type constant to name""" + type_map = { + pcbnew.LT_SIGNAL: "signal", + pcbnew.LT_POWER: "power", + pcbnew.LT_MIXED: "mixed", + pcbnew.LT_JUMPER: "jumper", + } + # Note: LT_USER was removed in KiCAD 9.0 + return type_map.get(type_id, "unknown") diff --git a/python/commands/board/outline.py b/python/commands/board/outline.py index 57825fc..c0e9642 100644 --- a/python/commands/board/outline.py +++ b/python/commands/board/outline.py @@ -1,484 +1,484 @@ -""" -Board outline command implementations for KiCAD interface -""" - -import logging -import math -from typing import Any, Dict, Optional - -import pcbnew - -logger = logging.getLogger("kicad_interface") - - -class BoardOutlineCommands: - """Handles board outline operations""" - - def __init__(self, board: Optional[pcbnew.BOARD] = None): - """Initialize with optional board instance""" - self.board = board - - def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a board outline to the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - # Claude sends dimensions nested inside a "params" key: - # {"shape": "rectangle", "params": {"x": 0, "y": 0, "width": 38, ...}} - # Unwrap the inner dict if present so we read dimensions from the right level. - inner = params.get("params", params) - - shape = params.get("shape", "rectangle") - width = inner.get("width") - height = inner.get("height") - radius = inner.get("radius") - # Accept both "cornerRadius" and "radius" regardless of shape name. - # The AI often sends shape="rectangle" with radius=2.5 — we treat that as rounded_rectangle. - corner_radius = inner.get("cornerRadius", inner.get("radius", 0)) - if shape == "rectangle" and corner_radius > 0: - shape = "rounded_rectangle" - points = inner.get("points", []) - unit = inner.get("unit", "mm") - - # Position: accept top-left corner (x/y) or center (centerX/centerY). - # Default: top-left at (0,0) so the board occupies positive coordinate space - # and is consistent with component placement coordinates. - x = inner.get("x") - y = inner.get("y") - if x is not None or y is not None: - ox = x if x is not None else 0.0 - oy = y if y is not None else 0.0 - center_x = ox + (width or 0) / 2.0 - center_y = oy + (height or 0) / 2.0 - else: - raw_cx = inner.get("centerX") - raw_cy = inner.get("centerY") - if raw_cx is not None or raw_cy is not None: - center_x = raw_cx if raw_cx is not None else 0.0 - center_y = raw_cy if raw_cy is not None else 0.0 - else: - # No position given → place top-left at (0,0) - center_x = (width or 0) / 2.0 - center_y = (height or 0) / 2.0 - - if shape not in ["rectangle", "circle", "polygon", "rounded_rectangle"]: - return { - "success": False, - "message": "Invalid shape", - "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", - } - - 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 - ) - - # 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", - } - - width_nm = int(width * scale) - height_nm = int(height * scale) - 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, - ) - - elif shape == "circle": - if radius is None: - return { - "success": False, - "message": "Missing radius", - "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) - circle.SetCenter(pcbnew.VECTOR2I(center_x_nm, center_y_nm)) - circle.SetEnd(pcbnew.VECTOR2I(center_x_nm + radius_nm, center_y_nm)) - circle.SetLayer(edge_layer) - circle.SetWidth(0) # Zero width for edge cuts - self.board.Add(circle) - - elif shape == "polygon": - if not points or len(points) < 3: - return { - "success": False, - "message": "Missing points", - "errorDetails": "At least 3 points are required for polygon", - } - - # Convert points to nm - polygon_points = [] - for point in points: - 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, - ) - - return { - "success": True, - "message": f"Added board outline: {shape}", - "outline": { - "shape": shape, - "width": width, - "height": height, - "center": {"x": center_x, "y": center_y, "unit": unit}, - "radius": radius, - "cornerRadius": corner_radius, - "points": points, - }, - } - - except Exception as e: - logger.error(f"Error adding board outline: {str(e)}") - return { - "success": False, - "message": "Failed to add board outline", - "errorDetails": str(e), - } - - def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a mounting hole to the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - position = params.get("position") - diameter = params.get("diameter") - pad_diameter = params.get("padDiameter") - plated = params.get("plated", False) - - if not position or not diameter: - return { - "success": False, - "message": "Missing parameters", - "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 - 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 - - # 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{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.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) - - return { - "success": True, - "message": "Added mounting hole", - "mountingHole": { - "position": position, - "diameter": diameter, - "padDiameter": pad_diameter or diameter + 1, - "plated": plated, - }, - } - - except Exception as e: - logger.error(f"Error adding mounting hole: {str(e)}") - return { - "success": False, - "message": "Failed to add mounting hole", - "errorDetails": str(e), - } - - def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add text annotation to the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - text = params.get("text") - position = params.get("position") - layer = params.get("layer", "F.SilkS") - size = params.get("size", 1.0) - thickness = params.get("thickness", 0.15) - rotation = params.get("rotation", 0) - mirror = params.get("mirror", False) - - if not text or not position: - return { - "success": False, - "message": "Missing parameters", - "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 - x_nm = int(position["x"] * scale) - y_nm = int(position["y"] * scale) - size_nm = int(size * scale) - thickness_nm = int(thickness * scale) - - # Get layer ID - layer_id = self.board.GetLayerID(layer) - if layer_id < 0: - return { - "success": False, - "message": "Invalid layer", - "errorDetails": f"Layer '{layer}' does not exist", - } - - # Create text - pcb_text = pcbnew.PCB_TEXT(self.board) - pcb_text.SetText(text) - pcb_text.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) - pcb_text.SetLayer(layer_id) - pcb_text.SetTextSize(pcbnew.VECTOR2I(size_nm, size_nm)) - pcb_text.SetTextThickness(thickness_nm) - - # Set rotation angle - KiCAD 9.0 uses EDA_ANGLE - try: - # Try KiCAD 9.0+ API (EDA_ANGLE) - angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T) - pcb_text.SetTextAngle(angle) - except (AttributeError, TypeError): - # Fall back to older API (decidegrees as integer) - pcb_text.SetTextAngle(int(rotation * 10)) - - pcb_text.SetMirrored(mirror) - - # Add to board - self.board.Add(pcb_text) - - return { - "success": True, - "message": "Added text annotation", - "text": { - "text": text, - "position": position, - "layer": layer, - "size": size, - "thickness": thickness, - "rotation": rotation, - "mirror": mirror, - }, - } - - except Exception as e: - logger.error(f"Error adding text: {str(e)}") - return { - "success": False, - "message": "Failed to add text", - "errorDetails": str(e), - } - - 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) - line.SetStart(start) - line.SetEnd(end) - line.SetLayer(layer) - 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: - """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) - - 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 - ) - top_right_center = pcbnew.VECTOR2I( - 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 - ) - bottom_left_center = pcbnew.VECTOR2I( - 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, - ) - # 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, - ) - # 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, - ) - # 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, - ) - - 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) - arc.SetWidth(0) # Zero width for edge cuts - self.board.Add(arc) +""" +Board outline command implementations for KiCAD interface +""" + +import logging +import math +from typing import Any, Dict, Optional + +import pcbnew + +logger = logging.getLogger("kicad_interface") + + +class BoardOutlineCommands: + """Handles board outline operations""" + + def __init__(self, board: Optional[pcbnew.BOARD] = None): + """Initialize with optional board instance""" + self.board = board + + def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a board outline to the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + # Claude sends dimensions nested inside a "params" key: + # {"shape": "rectangle", "params": {"x": 0, "y": 0, "width": 38, ...}} + # Unwrap the inner dict if present so we read dimensions from the right level. + inner = params.get("params", params) + + shape = params.get("shape", "rectangle") + width = inner.get("width") + height = inner.get("height") + radius = inner.get("radius") + # Accept both "cornerRadius" and "radius" regardless of shape name. + # The AI often sends shape="rectangle" with radius=2.5 — we treat that as rounded_rectangle. + corner_radius = inner.get("cornerRadius", inner.get("radius", 0)) + if shape == "rectangle" and corner_radius > 0: + shape = "rounded_rectangle" + points = inner.get("points", []) + unit = inner.get("unit", "mm") + + # Position: accept top-left corner (x/y) or center (centerX/centerY). + # Default: top-left at (0,0) so the board occupies positive coordinate space + # and is consistent with component placement coordinates. + x = inner.get("x") + y = inner.get("y") + if x is not None or y is not None: + ox = x if x is not None else 0.0 + oy = y if y is not None else 0.0 + center_x = ox + (width or 0) / 2.0 + center_y = oy + (height or 0) / 2.0 + else: + raw_cx = inner.get("centerX") + raw_cy = inner.get("centerY") + if raw_cx is not None or raw_cy is not None: + center_x = raw_cx if raw_cx is not None else 0.0 + center_y = raw_cy if raw_cy is not None else 0.0 + else: + # No position given → place top-left at (0,0) + center_x = (width or 0) / 2.0 + center_y = (height or 0) / 2.0 + + if shape not in ["rectangle", "circle", "polygon", "rounded_rectangle"]: + return { + "success": False, + "message": "Invalid shape", + "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", + } + + 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 + ) + + # 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", + } + + width_nm = int(width * scale) + height_nm = int(height * scale) + 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, + ) + + elif shape == "circle": + if radius is None: + return { + "success": False, + "message": "Missing radius", + "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) + circle.SetCenter(pcbnew.VECTOR2I(center_x_nm, center_y_nm)) + circle.SetEnd(pcbnew.VECTOR2I(center_x_nm + radius_nm, center_y_nm)) + circle.SetLayer(edge_layer) + circle.SetWidth(0) # Zero width for edge cuts + self.board.Add(circle) + + elif shape == "polygon": + if not points or len(points) < 3: + return { + "success": False, + "message": "Missing points", + "errorDetails": "At least 3 points are required for polygon", + } + + # Convert points to nm + polygon_points = [] + for point in points: + 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, + ) + + return { + "success": True, + "message": f"Added board outline: {shape}", + "outline": { + "shape": shape, + "width": width, + "height": height, + "center": {"x": center_x, "y": center_y, "unit": unit}, + "radius": radius, + "cornerRadius": corner_radius, + "points": points, + }, + } + + except Exception as e: + logger.error(f"Error adding board outline: {str(e)}") + return { + "success": False, + "message": "Failed to add board outline", + "errorDetails": str(e), + } + + def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a mounting hole to the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + position = params.get("position") + diameter = params.get("diameter") + pad_diameter = params.get("padDiameter") + plated = params.get("plated", False) + + if not position or not diameter: + return { + "success": False, + "message": "Missing parameters", + "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 + 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 + + # 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{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.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) + + return { + "success": True, + "message": "Added mounting hole", + "mountingHole": { + "position": position, + "diameter": diameter, + "padDiameter": pad_diameter or diameter + 1, + "plated": plated, + }, + } + + except Exception as e: + logger.error(f"Error adding mounting hole: {str(e)}") + return { + "success": False, + "message": "Failed to add mounting hole", + "errorDetails": str(e), + } + + def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add text annotation to the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + text = params.get("text") + position = params.get("position") + layer = params.get("layer", "F.SilkS") + size = params.get("size", 1.0) + thickness = params.get("thickness", 0.15) + rotation = params.get("rotation", 0) + mirror = params.get("mirror", False) + + if not text or not position: + return { + "success": False, + "message": "Missing parameters", + "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 + x_nm = int(position["x"] * scale) + y_nm = int(position["y"] * scale) + size_nm = int(size * scale) + thickness_nm = int(thickness * scale) + + # Get layer ID + layer_id = self.board.GetLayerID(layer) + if layer_id < 0: + return { + "success": False, + "message": "Invalid layer", + "errorDetails": f"Layer '{layer}' does not exist", + } + + # Create text + pcb_text = pcbnew.PCB_TEXT(self.board) + pcb_text.SetText(text) + pcb_text.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) + pcb_text.SetLayer(layer_id) + pcb_text.SetTextSize(pcbnew.VECTOR2I(size_nm, size_nm)) + pcb_text.SetTextThickness(thickness_nm) + + # Set rotation angle - KiCAD 9.0 uses EDA_ANGLE + try: + # Try KiCAD 9.0+ API (EDA_ANGLE) + angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T) + pcb_text.SetTextAngle(angle) + except (AttributeError, TypeError): + # Fall back to older API (decidegrees as integer) + pcb_text.SetTextAngle(int(rotation * 10)) + + pcb_text.SetMirrored(mirror) + + # Add to board + self.board.Add(pcb_text) + + return { + "success": True, + "message": "Added text annotation", + "text": { + "text": text, + "position": position, + "layer": layer, + "size": size, + "thickness": thickness, + "rotation": rotation, + "mirror": mirror, + }, + } + + except Exception as e: + logger.error(f"Error adding text: {str(e)}") + return { + "success": False, + "message": "Failed to add text", + "errorDetails": str(e), + } + + 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) + line.SetStart(start) + line.SetEnd(end) + line.SetLayer(layer) + 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: + """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) + + 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 + ) + top_right_center = pcbnew.VECTOR2I( + 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 + ) + bottom_left_center = pcbnew.VECTOR2I( + 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, + ) + # 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, + ) + # 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, + ) + # 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, + ) + + 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) + arc.SetWidth(0) # Zero width for edge cuts + self.board.Add(arc) diff --git a/python/commands/board/size.py b/python/commands/board/size.py index 243b2ca..02ed748 100644 --- a/python/commands/board/size.py +++ b/python/commands/board/size.py @@ -1,70 +1,70 @@ -""" -Board size command implementations for KiCAD interface -""" - -import logging -from typing import Any, Dict, Optional - -import pcbnew - -logger = logging.getLogger("kicad_interface") - - -class BoardSizeCommands: - """Handles board size operations""" - - def __init__(self, board: Optional[pcbnew.BOARD] = None): - """Initialize with optional board instance""" - self.board = board - - def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Set the size of the PCB board by creating edge cuts outline""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - width = params.get("width") - height = params.get("height") - unit = params.get("unit", "mm") - - if width is None or height is None: - return { - "success": False, - "message": "Missing dimensions", - "errorDetails": "Both width and height are required", - } - - # Create board outline using BoardOutlineCommands - # This properly creates edge cuts on Edge.Cuts layer - from commands.board.outline import BoardOutlineCommands - - outline_commands = BoardOutlineCommands(self.board) - - # Create rectangular outline centered at origin - result = outline_commands.add_board_outline( - { - "shape": "rectangle", - "centerX": width / 2, # Center X - "centerY": height / 2, # Center Y - "width": width, - "height": height, - "unit": unit, - } - ) - - if result.get("success"): - return { - "success": True, - "message": f"Created board outline: {width}x{height} {unit}", - "size": {"width": width, "height": height, "unit": unit}, - } - else: - return result - - except Exception as e: - logger.error(f"Error setting board size: {str(e)}") - return {"success": False, "message": "Failed to set board size", "errorDetails": str(e)} +""" +Board size command implementations for KiCAD interface +""" + +import logging +from typing import Any, Dict, Optional + +import pcbnew + +logger = logging.getLogger("kicad_interface") + + +class BoardSizeCommands: + """Handles board size operations""" + + def __init__(self, board: Optional[pcbnew.BOARD] = None): + """Initialize with optional board instance""" + self.board = board + + def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Set the size of the PCB board by creating edge cuts outline""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + width = params.get("width") + height = params.get("height") + unit = params.get("unit", "mm") + + if width is None or height is None: + return { + "success": False, + "message": "Missing dimensions", + "errorDetails": "Both width and height are required", + } + + # Create board outline using BoardOutlineCommands + # This properly creates edge cuts on Edge.Cuts layer + from commands.board.outline import BoardOutlineCommands + + outline_commands = BoardOutlineCommands(self.board) + + # Create rectangular outline centered at origin + result = outline_commands.add_board_outline( + { + "shape": "rectangle", + "centerX": width / 2, # Center X + "centerY": height / 2, # Center Y + "width": width, + "height": height, + "unit": unit, + } + ) + + if result.get("success"): + return { + "success": True, + "message": f"Created board outline: {width}x{height} {unit}", + "size": {"width": width, "height": height, "unit": unit}, + } + else: + return result + + except Exception as e: + logger.error(f"Error setting board size: {str(e)}") + return {"success": False, "message": "Failed to set board size", "errorDetails": str(e)} diff --git a/python/commands/board/view.py b/python/commands/board/view.py index baee5cd..0d5859d 100644 --- a/python/commands/board/view.py +++ b/python/commands/board/view.py @@ -1,226 +1,226 @@ -""" -Board view command implementations for KiCAD interface -""" - -import base64 -import io -import logging -import os -from typing import Any, Dict, List, Optional, Tuple - -import pcbnew -from PIL import Image - -logger = logging.getLogger("kicad_interface") - - -class BoardViewCommands: - """Handles board viewing operations""" - - def __init__(self, board: Optional[pcbnew.BOARD] = None): - """Initialize with optional board instance""" - self.board = board - - def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get information about the current board""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - # Get board dimensions - board_box = self.board.GetBoardEdgesBoundingBox() - width_nm = board_box.GetWidth() - height_nm = board_box.GetHeight() - - # Convert to mm - width_mm = width_nm / 1000000 - height_mm = height_nm / 1000000 - - # Get layer information - layers = [] - for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): - if self.board.IsLayerEnabled(layer_id): - layers.append( - { - "name": self.board.GetLayerName(layer_id), - "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), - "id": layer_id, - } - ) - - return { - "success": True, - "board": { - "filename": self.board.GetFileName(), - "size": {"width": width_mm, "height": height_mm, "unit": "mm"}, - "layers": layers, - "title": self.board.GetTitleBlock().GetTitle(), - # Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0 - # Active layer is a UI concept not applicable to headless scripting - }, - } - - except Exception as e: - logger.error(f"Error getting board info: {str(e)}") - return { - "success": False, - "message": "Failed to get board information", - "errorDetails": str(e), - } - - def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get a 2D image of the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - # Get parameters - width = params.get("width", 800) - height = params.get("height", 600) - format = params.get("format", "png") - layers = params.get("layers", []) - - # Create plot controller - plotter = pcbnew.PLOT_CONTROLLER(self.board) - - # Set up plot options - plot_opts = plotter.GetPlotOptions() - plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName())) - plot_opts.SetScale(1) - plot_opts.SetMirror(False) - # Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers - plot_opts.SetPlotFrameRef(False) - plot_opts.SetPlotValue(True) - plot_opts.SetPlotReference(True) - - # Plot to SVG first (for vector output) - # Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path - plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View") - - # Plot specified layers or all enabled layers - # Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer() - if layers: - for layer_name in layers: - layer_id = self.board.GetLayerID(layer_name) - if layer_id >= 0 and self.board.IsLayerEnabled(layer_id): - plotter.SetLayer(layer_id) - plotter.PlotLayer() - else: - for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): - if self.board.IsLayerEnabled(layer_id): - plotter.SetLayer(layer_id) - plotter.PlotLayer() - - # Get the actual filename that was created (includes project name prefix) - temp_svg = plotter.GetPlotFileName() - - plotter.ClosePlot() - - # Convert SVG to requested format - if format == "svg": - with open(temp_svg, "r") as f: - svg_data = f.read() - os.remove(temp_svg) - return {"success": True, "imageData": svg_data, "format": "svg"} - else: - # Use PIL to convert SVG to PNG/JPG - from cairosvg import svg2png - - png_data = svg2png(url=temp_svg, output_width=width, output_height=height) - os.remove(temp_svg) - - if format == "jpg": - # Convert PNG to JPG - img = Image.open(io.BytesIO(png_data)) - jpg_buffer = io.BytesIO() - img.convert("RGB").save(jpg_buffer, format="JPEG") - jpg_data = jpg_buffer.getvalue() - return { - "success": True, - "imageData": base64.b64encode(jpg_data).decode("utf-8"), - "format": "jpg", - } - else: - return { - "success": True, - "imageData": base64.b64encode(png_data).decode("utf-8"), - "format": "png", - } - - except Exception as e: - logger.error(f"Error getting board 2D view: {str(e)}") - return { - "success": False, - "message": "Failed to get board 2D view", - "errorDetails": str(e), - } - - def _get_layer_type_name(self, type_id: int) -> str: - """Convert KiCAD layer type constant to name""" - type_map = { - pcbnew.LT_SIGNAL: "signal", - pcbnew.LT_POWER: "power", - pcbnew.LT_MIXED: "mixed", - pcbnew.LT_JUMPER: "jumper", - } - # Note: LT_USER was removed in KiCAD 9.0 - return type_map.get(type_id, "unknown") - - def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get the bounding box extents of the board""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - # Get unit preference (default to mm) - unit = params.get("unit", "mm") - scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch - - # Get board bounding box - board_box = self.board.GetBoardEdgesBoundingBox() - - # Extract bounds in nanometers, then convert - left = board_box.GetLeft() / scale - top = board_box.GetTop() / scale - right = board_box.GetRight() / scale - bottom = board_box.GetBottom() / scale - width = board_box.GetWidth() / scale - height = board_box.GetHeight() / scale - - # Get center point - center_x = board_box.GetCenter().x / scale - center_y = board_box.GetCenter().y / scale - - return { - "success": True, - "extents": { - "left": left, - "top": top, - "right": right, - "bottom": bottom, - "width": width, - "height": height, - "center": {"x": center_x, "y": center_y}, - "unit": unit, - }, - } - - except Exception as e: - logger.error(f"Error getting board extents: {str(e)}") - return { - "success": False, - "message": "Failed to get board extents", - "errorDetails": str(e), - } +""" +Board view command implementations for KiCAD interface +""" + +import base64 +import io +import logging +import os +from typing import Any, Dict, List, Optional, Tuple + +import pcbnew +from PIL import Image + +logger = logging.getLogger("kicad_interface") + + +class BoardViewCommands: + """Handles board viewing operations""" + + def __init__(self, board: Optional[pcbnew.BOARD] = None): + """Initialize with optional board instance""" + self.board = board + + def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get information about the current board""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + # Get board dimensions + board_box = self.board.GetBoardEdgesBoundingBox() + width_nm = board_box.GetWidth() + height_nm = board_box.GetHeight() + + # Convert to mm + width_mm = width_nm / 1000000 + height_mm = height_nm / 1000000 + + # Get layer information + layers = [] + for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): + if self.board.IsLayerEnabled(layer_id): + layers.append( + { + "name": self.board.GetLayerName(layer_id), + "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), + "id": layer_id, + } + ) + + return { + "success": True, + "board": { + "filename": self.board.GetFileName(), + "size": {"width": width_mm, "height": height_mm, "unit": "mm"}, + "layers": layers, + "title": self.board.GetTitleBlock().GetTitle(), + # Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0 + # Active layer is a UI concept not applicable to headless scripting + }, + } + + except Exception as e: + logger.error(f"Error getting board info: {str(e)}") + return { + "success": False, + "message": "Failed to get board information", + "errorDetails": str(e), + } + + def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get a 2D image of the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + # Get parameters + width = params.get("width", 800) + height = params.get("height", 600) + format = params.get("format", "png") + layers = params.get("layers", []) + + # Create plot controller + plotter = pcbnew.PLOT_CONTROLLER(self.board) + + # Set up plot options + plot_opts = plotter.GetPlotOptions() + plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName())) + plot_opts.SetScale(1) + plot_opts.SetMirror(False) + # Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers + plot_opts.SetPlotFrameRef(False) + plot_opts.SetPlotValue(True) + plot_opts.SetPlotReference(True) + + # Plot to SVG first (for vector output) + # Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path + plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View") + + # Plot specified layers or all enabled layers + # Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer() + if layers: + for layer_name in layers: + layer_id = self.board.GetLayerID(layer_name) + if layer_id >= 0 and self.board.IsLayerEnabled(layer_id): + plotter.SetLayer(layer_id) + plotter.PlotLayer() + else: + for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): + if self.board.IsLayerEnabled(layer_id): + plotter.SetLayer(layer_id) + plotter.PlotLayer() + + # Get the actual filename that was created (includes project name prefix) + temp_svg = plotter.GetPlotFileName() + + plotter.ClosePlot() + + # Convert SVG to requested format + if format == "svg": + with open(temp_svg, "r") as f: + svg_data = f.read() + os.remove(temp_svg) + return {"success": True, "imageData": svg_data, "format": "svg"} + else: + # Use PIL to convert SVG to PNG/JPG + from cairosvg import svg2png + + png_data = svg2png(url=temp_svg, output_width=width, output_height=height) + os.remove(temp_svg) + + if format == "jpg": + # Convert PNG to JPG + img = Image.open(io.BytesIO(png_data)) + jpg_buffer = io.BytesIO() + img.convert("RGB").save(jpg_buffer, format="JPEG") + jpg_data = jpg_buffer.getvalue() + return { + "success": True, + "imageData": base64.b64encode(jpg_data).decode("utf-8"), + "format": "jpg", + } + else: + return { + "success": True, + "imageData": base64.b64encode(png_data).decode("utf-8"), + "format": "png", + } + + except Exception as e: + logger.error(f"Error getting board 2D view: {str(e)}") + return { + "success": False, + "message": "Failed to get board 2D view", + "errorDetails": str(e), + } + + def _get_layer_type_name(self, type_id: int) -> str: + """Convert KiCAD layer type constant to name""" + type_map = { + pcbnew.LT_SIGNAL: "signal", + pcbnew.LT_POWER: "power", + pcbnew.LT_MIXED: "mixed", + pcbnew.LT_JUMPER: "jumper", + } + # Note: LT_USER was removed in KiCAD 9.0 + return type_map.get(type_id, "unknown") + + def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get the bounding box extents of the board""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + # Get unit preference (default to mm) + unit = params.get("unit", "mm") + scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch + + # Get board bounding box + board_box = self.board.GetBoardEdgesBoundingBox() + + # Extract bounds in nanometers, then convert + left = board_box.GetLeft() / scale + top = board_box.GetTop() / scale + right = board_box.GetRight() / scale + bottom = board_box.GetBottom() / scale + width = board_box.GetWidth() / scale + height = board_box.GetHeight() / scale + + # Get center point + center_x = board_box.GetCenter().x / scale + center_y = board_box.GetCenter().y / scale + + return { + "success": True, + "extents": { + "left": left, + "top": top, + "right": right, + "bottom": bottom, + "width": width, + "height": height, + "center": {"x": center_x, "y": center_y}, + "unit": unit, + }, + } + + except Exception as e: + logger.error(f"Error getting board extents: {str(e)}") + return { + "success": False, + "message": "Failed to get board extents", + "errorDetails": str(e), + } diff --git a/python/commands/component.py b/python/commands/component.py index 4de1369..f7299ae 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -1,1212 +1,1212 @@ -""" -Component-related command implementations for KiCAD interface -""" - -import base64 -import logging -import math -import os -from typing import Any, Dict, List, Optional, Tuple - -import pcbnew -from commands.library import LibraryManager - -logger = logging.getLogger("kicad_interface") - - -class ComponentCommands: - """Handles component-related KiCAD operations""" - - def __init__( - self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None - ): - """Initialize with optional board instance and library manager""" - self.board = board - self.library_manager = library_manager or LibraryManager() - - def place_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Place a component on the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - # Get parameters - component_id = params.get("componentId") - position = params.get("position") - reference = params.get("reference") - value = params.get("value") - footprint = params.get("footprint") - rotation = params.get("rotation", 0) - layer = params.get("layer", "F.Cu") - - if not component_id or not position: - return { - "success": False, - "message": "Missing parameters", - "errorDetails": "componentId and position are required", - } - - # Find footprint using library manager - # component_id can be "Library:Footprint" or just "Footprint" - footprint_result = self.library_manager.find_footprint(component_id) - - if not footprint_result: - # Try to suggest similar footprints - suggestions = self.library_manager.search_footprints(f"*{component_id}*", limit=5) - suggestion_text = "" - if suggestions: - suggestion_text = "\n\nDid you mean one of these?\n" + "\n".join( - [f" - {s['full_name']}" for s in suggestions] - ) - - return { - "success": False, - "message": "Footprint not found", - "errorDetails": f"Could not find footprint: {component_id}{suggestion_text}", - } - - library_path, footprint_name = footprint_result - - # Load footprint from library - # Extract library nickname from path - library_nickname = None - for nick, path in self.library_manager.libraries.items(): - if path == library_path: - library_nickname = nick - break - - if not library_nickname: - return { - "success": False, - "message": "Internal error", - "errorDetails": "Could not determine library nickname", - } - - # Load the footprint - module = pcbnew.FootprintLoad(library_path, footprint_name) - if not module: - return { - "success": False, - "message": "Failed to load footprint", - "errorDetails": f"Could not load footprint from {library_path}/{footprint_name}", - } - - # Set position - scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm - x_nm = int(position["x"] * scale) - y_nm = int(position["y"] * scale) - module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) - - # Set reference if provided - if reference: - module.SetReference(reference) - - # Set value if provided - if value: - module.SetValue(value) - - # Set footprint if provided (use existing library_nickname and footprint_name) - # For KiCAD 9.x compatibility, use SetFPID instead of SetFootprintName - if footprint: - # Parse footprint string if it's in "Library:Footprint" format - if ":" in footprint: - lib_name, fp_name = footprint.split(":", 1) - else: - # Use the library_nickname we already have from loading - lib_name = library_nickname - fp_name = footprint - fpid = pcbnew.LIB_ID(lib_name, fp_name) - module.SetFPID(fpid) - else: - # Use the footprint we just loaded - fpid = pcbnew.LIB_ID(library_nickname, footprint_name) - module.SetFPID(fpid) - - # Set rotation (KiCAD 9.0 uses EDA_ANGLE) - angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T) - module.SetOrientation(angle) - - # Set layer for F.Cu (or non-B.Cu) before adding to board - if layer != "B.Cu": - layer_id = self.board.GetLayerID(layer) - if layer_id >= 0: - module.SetLayer(layer_id) - - # Add to board first — Flip() requires board context in KiCAD 9 - self.board.Add(module) - - # Flip to B.Cu after add (board context needed, otherwise hangs 30s) - if layer == "B.Cu": - if not module.IsFlipped(): - module.Flip(module.GetPosition(), False) - - return { - "success": True, - "message": f"Placed component: {component_id}", - "component": { - "reference": module.GetReference(), - "value": module.GetValue(), - "position": {"x": position["x"], "y": position["y"], "unit": position["unit"]}, - "rotation": rotation, - "layer": layer, - }, - } - - except Exception as e: - logger.error(f"Error placing component: {str(e)}") - return { - "success": False, - "message": "Failed to place component", - "errorDetails": str(e), - } - - def move_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Move an existing component to a new position""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - reference = params.get("reference") - position = params.get("position") - rotation = params.get("rotation") - layer = params.get("layer") - - if not reference or not position: - return { - "success": False, - "message": "Missing parameters", - "errorDetails": "reference and position are required", - } - - # Find the component - module = self.board.FindFootprintByReference(reference) - if not module: - return { - "success": False, - "message": "Component not found", - "errorDetails": f"Could not find component: {reference}", - } - - # Set new position - scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm - x_nm = int(position["x"] * scale) - y_nm = int(position["y"] * scale) - module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) - - # Set new rotation if provided - if rotation is not None: - angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T) - module.SetOrientation(angle) - - # Flip to target layer if specified - if layer: - current_layer = self.board.GetLayerName(module.GetLayer()) - if layer == "B.Cu" and current_layer != "B.Cu": - module.Flip(module.GetPosition(), False) - elif layer == "F.Cu" and current_layer != "F.Cu": - module.Flip(module.GetPosition(), False) - - return { - "success": True, - "message": f"Moved component: {reference}", - "component": { - "reference": reference, - "position": {"x": position["x"], "y": position["y"], "unit": position["unit"]}, - "rotation": ( - rotation if rotation is not None else module.GetOrientation().AsDegrees() - ), - "layer": self.board.GetLayerName(module.GetLayer()), - }, - } - - except Exception as e: - logger.error(f"Error moving component: {str(e)}") - return {"success": False, "message": "Failed to move component", "errorDetails": str(e)} - - def rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Rotate an existing component""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - reference = params.get("reference") - angle = params.get("angle") - - if not reference or angle is None: - return { - "success": False, - "message": "Missing parameters", - "errorDetails": "reference and angle are required", - } - - # Find the component - module = self.board.FindFootprintByReference(reference) - if not module: - return { - "success": False, - "message": "Component not found", - "errorDetails": f"Could not find component: {reference}", - } - - # Set rotation - rotation_angle = pcbnew.EDA_ANGLE(angle, pcbnew.DEGREES_T) - module.SetOrientation(rotation_angle) - - return { - "success": True, - "message": f"Rotated component: {reference}", - "component": {"reference": reference, "rotation": angle}, - } - - except Exception as e: - logger.error(f"Error rotating component: {str(e)}") - return { - "success": False, - "message": "Failed to rotate component", - "errorDetails": str(e), - } - - def delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Delete a component from the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - reference = params.get("reference") - if not reference: - return { - "success": False, - "message": "Missing reference", - "errorDetails": "reference parameter is required", - } - - # Find the component - module = self.board.FindFootprintByReference(reference) - if not module: - return { - "success": False, - "message": "Component not found", - "errorDetails": f"Could not find component: {reference}", - } - - # Remove from board - self.board.Remove(module) - - return {"success": True, "message": f"Deleted component: {reference}"} - - except Exception as e: - logger.error(f"Error deleting component: {str(e)}") - return { - "success": False, - "message": "Failed to delete component", - "errorDetails": str(e), - } - - def edit_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Edit the properties of an existing component""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - reference = params.get("reference") - new_reference = params.get("newReference") - value = params.get("value") - footprint = params.get("footprint") - - if not reference: - return { - "success": False, - "message": "Missing reference", - "errorDetails": "reference parameter is required", - } - - # Find the component - module = self.board.FindFootprintByReference(reference) - if not module: - return { - "success": False, - "message": "Component not found", - "errorDetails": f"Could not find component: {reference}", - } - - # Update properties - if new_reference: - module.SetReference(new_reference) - if value: - module.SetValue(value) - if footprint: - # For KiCAD 9.x compatibility, use SetFPID instead of SetFootprintName - # Parse footprint string (format: "Library:Footprint") - if ":" in footprint: - lib_name, fp_name = footprint.split(":", 1) - fpid = pcbnew.LIB_ID(lib_name, fp_name) - module.SetFPID(fpid) - else: - # If no library specified, keep existing library - current_fpid = module.GetFPID() - lib_name = current_fpid.GetLibNickname().GetUTF8() - fpid = pcbnew.LIB_ID(lib_name, footprint) - module.SetFPID(fpid) - - return { - "success": True, - "message": f"Updated component: {reference}", - "component": { - "reference": new_reference or reference, - "value": value or module.GetValue(), - "footprint": footprint or module.GetFPIDAsString(), - }, - } - - except Exception as e: - logger.error(f"Error editing component: {str(e)}") - return {"success": False, "message": "Failed to edit component", "errorDetails": str(e)} - - def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get detailed properties of a component""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - reference = params.get("reference") - if not reference: - return { - "success": False, - "message": "Missing reference", - "errorDetails": "reference parameter is required", - } - - # Find the component - module = self.board.FindFootprintByReference(reference) - if not module: - return { - "success": False, - "message": "Component not found", - "errorDetails": f"Could not find component: {reference}", - } - - # Get position in mm - pos = module.GetPosition() - x_mm = pos.x / 1000000 - y_mm = pos.y / 1000000 - - return { - "success": True, - "component": { - "reference": module.GetReference(), - "value": module.GetValue(), - "footprint": module.GetFPIDAsString(), - "position": {"x": x_mm, "y": y_mm, "unit": "mm"}, - "rotation": module.GetOrientation().AsDegrees(), - "layer": self.board.GetLayerName(module.GetLayer()), - "attributes": { - "smd": module.GetAttributes() & pcbnew.FP_SMD, - "through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE, - "board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY, - }, - }, - } - - except Exception as e: - logger.error(f"Error getting component properties: {str(e)}") - return { - "success": False, - "message": "Failed to get component properties", - "errorDetails": str(e), - } - - def get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get a list of all components on the board""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - components = [] - for module in self.board.GetFootprints(): - pos = module.GetPosition() - x_mm = pos.x / 1000000 - y_mm = pos.y / 1000000 - - components.append( - { - "reference": module.GetReference(), - "value": module.GetValue(), - "footprint": module.GetFPIDAsString(), - "position": {"x": x_mm, "y": y_mm, "unit": "mm"}, - "rotation": module.GetOrientation().AsDegrees(), - "layer": self.board.GetLayerName(module.GetLayer()), - } - ) - - return {"success": True, "components": components} - - except Exception as e: - logger.error(f"Error getting component list: {str(e)}") - return { - "success": False, - "message": "Failed to get component list", - "errorDetails": str(e), - } - - def find_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Find components matching search criteria (reference, value, or footprint pattern)""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - # Get search parameters - reference_pattern = params.get("reference", "").lower() - value_pattern = params.get("value", "").lower() - footprint_pattern = params.get("footprint", "").lower() - - if not reference_pattern and not value_pattern and not footprint_pattern: - return { - "success": False, - "message": "Missing search criteria", - "errorDetails": "At least one of reference, value, or footprint pattern is required", - } - - matches = [] - for module in self.board.GetFootprints(): - ref = module.GetReference().lower() - val = module.GetValue().lower() - fp = module.GetFPIDAsString().lower() - - # Check if component matches all provided patterns - match = True - if reference_pattern and reference_pattern not in ref: - match = False - if value_pattern and value_pattern not in val: - match = False - if footprint_pattern and footprint_pattern not in fp: - match = False - - if match: - pos = module.GetPosition() - matches.append( - { - "reference": module.GetReference(), - "value": module.GetValue(), - "footprint": module.GetFPIDAsString(), - "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, - "rotation": module.GetOrientation().AsDegrees(), - "layer": self.board.GetLayerName(module.GetLayer()), - } - ) - - return {"success": True, "matchCount": len(matches), "components": matches} - - except Exception as e: - logger.error(f"Error finding components: {str(e)}") - return { - "success": False, - "message": "Failed to find components", - "errorDetails": str(e), - } - - def get_component_pads(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get all pads for a component with their positions and net connections""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - reference = params.get("reference") - if not reference: - return { - "success": False, - "message": "Missing reference", - "errorDetails": "reference parameter is required", - } - - # Find the component - module = self.board.FindFootprintByReference(reference) - if not module: - return { - "success": False, - "message": "Component not found", - "errorDetails": f"Could not find component: {reference}", - } - - pads = [] - for pad in module.Pads(): - pos = pad.GetPosition() - size = pad.GetSize() - - # Get pad shape as string - shape_map = { - pcbnew.PAD_SHAPE_CIRCLE: "circle", - pcbnew.PAD_SHAPE_RECT: "rect", - pcbnew.PAD_SHAPE_OVAL: "oval", - pcbnew.PAD_SHAPE_TRAPEZOID: "trapezoid", - pcbnew.PAD_SHAPE_ROUNDRECT: "roundrect", - pcbnew.PAD_SHAPE_CHAMFERED_RECT: "chamfered_rect", - pcbnew.PAD_SHAPE_CUSTOM: "custom", - } - shape = shape_map.get(pad.GetShape(), "unknown") - - # Get pad type - type_map = { - pcbnew.PAD_ATTRIB_PTH: "through_hole", - pcbnew.PAD_ATTRIB_SMD: "smd", - pcbnew.PAD_ATTRIB_CONN: "connector", - pcbnew.PAD_ATTRIB_NPTH: "npth", - } - pad_type = type_map.get(pad.GetAttribute(), "unknown") - - pads.append( - { - "name": pad.GetName(), - "number": pad.GetNumber(), - "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, - "net": pad.GetNetname(), - "netCode": pad.GetNetCode(), - "shape": shape, - "type": pad_type, - "size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"}, - "drillSize": ( - pad.GetDrillSize().x / 1000000 if pad.GetDrillSize().x > 0 else None - ), - } - ) - - # Get component position for reference - comp_pos = module.GetPosition() - - return { - "success": True, - "reference": reference, - "componentPosition": { - "x": comp_pos.x / 1000000, - "y": comp_pos.y / 1000000, - "unit": "mm", - }, - "padCount": len(pads), - "pads": pads, - } - - except Exception as e: - logger.error(f"Error getting component pads: {str(e)}") - return { - "success": False, - "message": "Failed to get component pads", - "errorDetails": str(e), - } - - def get_pad_position(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get the position of a specific pad on a component""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - reference = params.get("reference") - pad_name = params.get("padName") or params.get("padNumber") - - if not reference: - return { - "success": False, - "message": "Missing reference", - "errorDetails": "reference parameter is required", - } - if not pad_name: - return { - "success": False, - "message": "Missing pad identifier", - "errorDetails": "padName or padNumber parameter is required", - } - - # Find the component - module = self.board.FindFootprintByReference(reference) - if not module: - return { - "success": False, - "message": "Component not found", - "errorDetails": f"Could not find component: {reference}", - } - - # Find the specific pad - pad = module.FindPadByNumber(str(pad_name)) - if not pad: - # List available pads in error message - available_pads = [p.GetNumber() for p in module.Pads()] - return { - "success": False, - "message": "Pad not found", - "errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}", - } - - pos = pad.GetPosition() - size = pad.GetSize() - - return { - "success": True, - "reference": reference, - "padName": pad.GetNumber(), - "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, - "net": pad.GetNetname(), - "netCode": pad.GetNetCode(), - "size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"}, - } - - except Exception as e: - logger.error(f"Error getting pad position: {str(e)}") - return { - "success": False, - "message": "Failed to get pad position", - "errorDetails": str(e), - } - - def place_component_array(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Place an array of components in a grid or circular pattern""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - component_id = params.get("componentId") - pattern = params.get("pattern", "grid") # grid or circular - count = params.get("count") - reference_prefix = params.get("referencePrefix", "U") - value = params.get("value") - - if not component_id or not count: - return { - "success": False, - "message": "Missing parameters", - "errorDetails": "componentId and count are required", - } - - if pattern == "grid": - start_position = params.get("startPosition") - rows = params.get("rows") - columns = params.get("columns") - spacing_x = params.get("spacingX") - spacing_y = params.get("spacingY") - rotation = params.get("rotation", 0) - layer = params.get("layer", "F.Cu") - - if not start_position or not rows or not columns or not spacing_x or not spacing_y: - return { - "success": False, - "message": "Missing grid parameters", - "errorDetails": "For grid pattern, startPosition, rows, columns, spacingX, and spacingY are required", - } - - if rows * columns != count: - return { - "success": False, - "message": "Invalid grid parameters", - "errorDetails": "rows * columns must equal count", - } - - placed_components = self._place_grid_array( - component_id, - start_position, - rows, - columns, - spacing_x, - spacing_y, - reference_prefix, - value, - rotation, - layer, - ) - - elif pattern == "circular": - center = params.get("center") - radius = params.get("radius") - angle_start = params.get("angleStart", 0) - angle_step = params.get("angleStep") - rotation_offset = params.get("rotationOffset", 0) - layer = params.get("layer", "F.Cu") - - if not center or not radius or not angle_step: - return { - "success": False, - "message": "Missing circular parameters", - "errorDetails": "For circular pattern, center, radius, and angleStep are required", - } - - placed_components = self._place_circular_array( - component_id, - center, - radius, - count, - angle_start, - angle_step, - reference_prefix, - value, - rotation_offset, - layer, - ) - - else: - return { - "success": False, - "message": "Invalid pattern", - "errorDetails": "Pattern must be 'grid' or 'circular'", - } - - return { - "success": True, - "message": f"Placed {count} components in {pattern} pattern", - "components": placed_components, - } - - except Exception as e: - logger.error(f"Error placing component array: {str(e)}") - return { - "success": False, - "message": "Failed to place component array", - "errorDetails": str(e), - } - - def align_components(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Align multiple components along a line or distribute them evenly""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - references = params.get("references", []) - alignment = params.get("alignment", "horizontal") # horizontal, vertical, or edge - distribution = params.get("distribution", "none") # none, equal, or spacing - spacing = params.get("spacing") - - if not references or len(references) < 2: - return { - "success": False, - "message": "Missing references", - "errorDetails": "At least two component references are required", - } - - # Find all referenced components - components = [] - for ref in references: - module = self.board.FindFootprintByReference(ref) - if not module: - return { - "success": False, - "message": "Component not found", - "errorDetails": f"Could not find component: {ref}", - } - components.append(module) - - # Perform alignment based on selected option - if alignment == "horizontal": - self._align_components_horizontally(components, distribution, spacing) - elif alignment == "vertical": - self._align_components_vertically(components, distribution, spacing) - elif alignment == "edge": - edge = params.get("edge") - if not edge: - return { - "success": False, - "message": "Missing edge parameter", - "errorDetails": "Edge parameter is required for edge alignment", - } - self._align_components_to_edge(components, edge) - else: - return { - "success": False, - "message": "Invalid alignment option", - "errorDetails": "Alignment must be 'horizontal', 'vertical', or 'edge'", - } - - # Prepare result data - aligned_components = [] - for module in components: - pos = module.GetPosition() - aligned_components.append( - { - "reference": module.GetReference(), - "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, - "rotation": module.GetOrientation().AsDegrees(), - } - ) - - return { - "success": True, - "message": f"Aligned {len(components)} components", - "alignment": alignment, - "distribution": distribution, - "components": aligned_components, - } - - except Exception as e: - logger.error(f"Error aligning components: {str(e)}") - return { - "success": False, - "message": "Failed to align components", - "errorDetails": str(e), - } - - def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Duplicate an existing component""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - reference = params.get("reference") - new_reference = params.get("newReference") - position = params.get("position") - rotation = params.get("rotation") - - if not reference or not new_reference: - return { - "success": False, - "message": "Missing parameters", - "errorDetails": "reference and newReference are required", - } - - # Find the source component - source = self.board.FindFootprintByReference(reference) - if not source: - return { - "success": False, - "message": "Component not found", - "errorDetails": f"Could not find component: {reference}", - } - - # Check if new reference already exists - if self.board.FindFootprintByReference(new_reference): - return { - "success": False, - "message": "Reference already exists", - "errorDetails": f"A component with reference {new_reference} already exists", - } - - # Create new footprint with the same properties - new_module = pcbnew.FOOTPRINT(self.board) - # For KiCAD 9.x compatibility, use SetFPID instead of SetFootprintName - new_module.SetFPID(source.GetFPID()) - new_module.SetValue(source.GetValue()) - new_module.SetReference(new_reference) - new_module.SetLayer(source.GetLayer()) - - # Copy pads and other items - for pad in source.Pads(): - new_pad = pcbnew.PAD(new_module) - new_pad.Copy(pad) - new_module.Add(new_pad) - - # Set position if provided, otherwise use offset from original - if position: - scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 - x_nm = int(position["x"] * scale) - y_nm = int(position["y"] * scale) - new_module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) - else: - # Offset by 5mm - source_pos = source.GetPosition() - new_module.SetPosition(pcbnew.VECTOR2I(source_pos.x + 5000000, source_pos.y)) - - # Set rotation if provided, otherwise use same as original - if rotation is not None: - rotation_angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T) - new_module.SetOrientation(rotation_angle) - else: - new_module.SetOrientation(source.GetOrientation()) - - # Add to board - self.board.Add(new_module) - - # Get final position in mm - pos = new_module.GetPosition() - - return { - "success": True, - "message": f"Duplicated component {reference} to {new_reference}", - "component": { - "reference": new_reference, - "value": new_module.GetValue(), - "footprint": new_module.GetFPIDAsString(), - "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, - "rotation": new_module.GetOrientation().AsDegrees(), - "layer": self.board.GetLayerName(new_module.GetLayer()), - }, - } - - except Exception as e: - logger.error(f"Error duplicating component: {str(e)}") - return { - "success": False, - "message": "Failed to duplicate component", - "errorDetails": str(e), - } - - def _place_grid_array( - self, - component_id: str, - start_position: Dict[str, Any], - rows: int, - columns: int, - spacing_x: float, - spacing_y: float, - reference_prefix: str, - value: str, - rotation: float, - layer: str, - ) -> List[Dict[str, Any]]: - """Place components in a grid pattern and return the list of placed components""" - placed = [] - - # Convert spacing to nm - unit = start_position.get("unit", "mm") - scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm - spacing_x_nm = int(spacing_x * scale) - spacing_y_nm = int(spacing_y * scale) - - # Get layer ID - layer_id = self.board.GetLayerID(layer) - - for row in range(rows): - for col in range(columns): - # Calculate position - x = start_position["x"] + (col * spacing_x) - y = start_position["y"] + (row * spacing_y) - - # Generate reference - index = row * columns + col + 1 - component_reference = f"{reference_prefix}{index}" - - # Place component - result = self.place_component( - { - "componentId": component_id, - "position": {"x": x, "y": y, "unit": unit}, - "reference": component_reference, - "value": value, - "rotation": rotation, - "layer": layer, - } - ) - - if result["success"]: - placed.append(result["component"]) - - return placed - - def _place_circular_array( - self, - component_id: str, - center: Dict[str, Any], - radius: float, - count: int, - angle_start: float, - angle_step: float, - reference_prefix: str, - value: str, - rotation_offset: float, - layer: str, - ) -> List[Dict[str, Any]]: - """Place components in a circular pattern and return the list of placed components""" - placed = [] - - # Get unit - unit = center.get("unit", "mm") - - for i in range(count): - # Calculate angle for this component - angle = angle_start + (i * angle_step) - angle_rad = math.radians(angle) - - # Calculate position - x = center["x"] + (radius * math.cos(angle_rad)) - y = center["y"] + (radius * math.sin(angle_rad)) - - # Generate reference - component_reference = f"{reference_prefix}{i+1}" - - # Calculate rotation (pointing outward from center) - component_rotation = angle + rotation_offset - - # Place component - result = self.place_component( - { - "componentId": component_id, - "position": {"x": x, "y": y, "unit": unit}, - "reference": component_reference, - "value": value, - "rotation": component_rotation, - "layer": layer, - } - ) - - if result["success"]: - placed.append(result["component"]) - - return placed - - def _align_components_horizontally( - self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float] - ) -> None: - """Align components horizontally and optionally distribute them""" - if not components: - return - - # Find the average Y coordinate - y_sum = sum(module.GetPosition().y for module in components) - y_avg = y_sum // len(components) - - # Sort components by X position - components.sort(key=lambda m: m.GetPosition().x) - - # Set Y coordinate for all components - for module in components: - pos = module.GetPosition() - module.SetPosition(pcbnew.VECTOR2I(pos.x, y_avg)) - - # Handle distribution if requested - if distribution == "equal" and len(components) > 1: - # Get leftmost and rightmost X coordinates - x_min = components[0].GetPosition().x - x_max = components[-1].GetPosition().x - - # Calculate equal spacing - total_space = x_max - x_min - spacing_nm = total_space // (len(components) - 1) - - # Set X positions with equal spacing - for i in range(1, len(components) - 1): - pos = components[i].GetPosition() - new_x = x_min + (i * spacing_nm) - components[i].SetPosition(pcbnew.VECTOR2I(new_x, pos.y)) - - elif distribution == "spacing" and spacing is not None: - # Convert spacing to nanometers - spacing_nm = int(spacing * 1000000) # assuming mm - - # Set X positions with the specified spacing - x_current = components[0].GetPosition().x - for i in range(1, len(components)): - pos = components[i].GetPosition() - x_current += spacing_nm - components[i].SetPosition(pcbnew.VECTOR2I(x_current, pos.y)) - - def _align_components_vertically( - self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float] - ) -> None: - """Align components vertically and optionally distribute them""" - if not components: - return - - # Find the average X coordinate - x_sum = sum(module.GetPosition().x for module in components) - x_avg = x_sum // len(components) - - # Sort components by Y position - components.sort(key=lambda m: m.GetPosition().y) - - # Set X coordinate for all components - for module in components: - pos = module.GetPosition() - module.SetPosition(pcbnew.VECTOR2I(x_avg, pos.y)) - - # Handle distribution if requested - if distribution == "equal" and len(components) > 1: - # Get topmost and bottommost Y coordinates - y_min = components[0].GetPosition().y - y_max = components[-1].GetPosition().y - - # Calculate equal spacing - total_space = y_max - y_min - spacing_nm = total_space // (len(components) - 1) - - # Set Y positions with equal spacing - for i in range(1, len(components) - 1): - pos = components[i].GetPosition() - new_y = y_min + (i * spacing_nm) - components[i].SetPosition(pcbnew.VECTOR2I(pos.x, new_y)) - - elif distribution == "spacing" and spacing is not None: - # Convert spacing to nanometers - spacing_nm = int(spacing * 1000000) # assuming mm - - # Set Y positions with the specified spacing - y_current = components[0].GetPosition().y - for i in range(1, len(components)): - pos = components[i].GetPosition() - y_current += spacing_nm - components[i].SetPosition(pcbnew.VECTOR2I(pos.x, y_current)) - - def _align_components_to_edge(self, components: List[pcbnew.FOOTPRINT], edge: str) -> None: - """Align components to the specified edge of the board""" - if not components: - return - - # Get board bounds - board_box = self.board.GetBoardEdgesBoundingBox() - left = board_box.GetLeft() - right = board_box.GetRight() - top = board_box.GetTop() - bottom = board_box.GetBottom() - - # Align based on specified edge - if edge == "left": - for module in components: - pos = module.GetPosition() - module.SetPosition(pcbnew.VECTOR2I(left + 2000000, pos.y)) # 2mm offset from edge - elif edge == "right": - for module in components: - pos = module.GetPosition() - module.SetPosition(pcbnew.VECTOR2I(right - 2000000, pos.y)) # 2mm offset from edge - elif edge == "top": - for module in components: - pos = module.GetPosition() - module.SetPosition(pcbnew.VECTOR2I(pos.x, top + 2000000)) # 2mm offset from edge - elif edge == "bottom": - for module in components: - pos = module.GetPosition() - module.SetPosition(pcbnew.VECTOR2I(pos.x, bottom - 2000000)) # 2mm offset from edge - else: - logger.warning(f"Unknown edge alignment: {edge}") +""" +Component-related command implementations for KiCAD interface +""" + +import base64 +import logging +import math +import os +from typing import Any, Dict, List, Optional, Tuple + +import pcbnew +from commands.library import LibraryManager + +logger = logging.getLogger("kicad_interface") + + +class ComponentCommands: + """Handles component-related KiCAD operations""" + + def __init__( + self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None + ): + """Initialize with optional board instance and library manager""" + self.board = board + self.library_manager = library_manager or LibraryManager() + + def place_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Place a component on the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + # Get parameters + component_id = params.get("componentId") + position = params.get("position") + reference = params.get("reference") + value = params.get("value") + footprint = params.get("footprint") + rotation = params.get("rotation", 0) + layer = params.get("layer", "F.Cu") + + if not component_id or not position: + return { + "success": False, + "message": "Missing parameters", + "errorDetails": "componentId and position are required", + } + + # Find footprint using library manager + # component_id can be "Library:Footprint" or just "Footprint" + footprint_result = self.library_manager.find_footprint(component_id) + + if not footprint_result: + # Try to suggest similar footprints + suggestions = self.library_manager.search_footprints(f"*{component_id}*", limit=5) + suggestion_text = "" + if suggestions: + suggestion_text = "\n\nDid you mean one of these?\n" + "\n".join( + [f" - {s['full_name']}" for s in suggestions] + ) + + return { + "success": False, + "message": "Footprint not found", + "errorDetails": f"Could not find footprint: {component_id}{suggestion_text}", + } + + library_path, footprint_name = footprint_result + + # Load footprint from library + # Extract library nickname from path + library_nickname = None + for nick, path in self.library_manager.libraries.items(): + if path == library_path: + library_nickname = nick + break + + if not library_nickname: + return { + "success": False, + "message": "Internal error", + "errorDetails": "Could not determine library nickname", + } + + # Load the footprint + module = pcbnew.FootprintLoad(library_path, footprint_name) + if not module: + return { + "success": False, + "message": "Failed to load footprint", + "errorDetails": f"Could not load footprint from {library_path}/{footprint_name}", + } + + # Set position + scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm + x_nm = int(position["x"] * scale) + y_nm = int(position["y"] * scale) + module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) + + # Set reference if provided + if reference: + module.SetReference(reference) + + # Set value if provided + if value: + module.SetValue(value) + + # Set footprint if provided (use existing library_nickname and footprint_name) + # For KiCAD 9.x compatibility, use SetFPID instead of SetFootprintName + if footprint: + # Parse footprint string if it's in "Library:Footprint" format + if ":" in footprint: + lib_name, fp_name = footprint.split(":", 1) + else: + # Use the library_nickname we already have from loading + lib_name = library_nickname + fp_name = footprint + fpid = pcbnew.LIB_ID(lib_name, fp_name) + module.SetFPID(fpid) + else: + # Use the footprint we just loaded + fpid = pcbnew.LIB_ID(library_nickname, footprint_name) + module.SetFPID(fpid) + + # Set rotation (KiCAD 9.0 uses EDA_ANGLE) + angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T) + module.SetOrientation(angle) + + # Set layer for F.Cu (or non-B.Cu) before adding to board + if layer != "B.Cu": + layer_id = self.board.GetLayerID(layer) + if layer_id >= 0: + module.SetLayer(layer_id) + + # Add to board first — Flip() requires board context in KiCAD 9 + self.board.Add(module) + + # Flip to B.Cu after add (board context needed, otherwise hangs 30s) + if layer == "B.Cu": + if not module.IsFlipped(): + module.Flip(module.GetPosition(), False) + + return { + "success": True, + "message": f"Placed component: {component_id}", + "component": { + "reference": module.GetReference(), + "value": module.GetValue(), + "position": {"x": position["x"], "y": position["y"], "unit": position["unit"]}, + "rotation": rotation, + "layer": layer, + }, + } + + except Exception as e: + logger.error(f"Error placing component: {str(e)}") + return { + "success": False, + "message": "Failed to place component", + "errorDetails": str(e), + } + + def move_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Move an existing component to a new position""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + reference = params.get("reference") + position = params.get("position") + rotation = params.get("rotation") + layer = params.get("layer") + + if not reference or not position: + return { + "success": False, + "message": "Missing parameters", + "errorDetails": "reference and position are required", + } + + # Find the component + module = self.board.FindFootprintByReference(reference) + if not module: + return { + "success": False, + "message": "Component not found", + "errorDetails": f"Could not find component: {reference}", + } + + # Set new position + scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm + x_nm = int(position["x"] * scale) + y_nm = int(position["y"] * scale) + module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) + + # Set new rotation if provided + if rotation is not None: + angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T) + module.SetOrientation(angle) + + # Flip to target layer if specified + if layer: + current_layer = self.board.GetLayerName(module.GetLayer()) + if layer == "B.Cu" and current_layer != "B.Cu": + module.Flip(module.GetPosition(), False) + elif layer == "F.Cu" and current_layer != "F.Cu": + module.Flip(module.GetPosition(), False) + + return { + "success": True, + "message": f"Moved component: {reference}", + "component": { + "reference": reference, + "position": {"x": position["x"], "y": position["y"], "unit": position["unit"]}, + "rotation": ( + rotation if rotation is not None else module.GetOrientation().AsDegrees() + ), + "layer": self.board.GetLayerName(module.GetLayer()), + }, + } + + except Exception as e: + logger.error(f"Error moving component: {str(e)}") + return {"success": False, "message": "Failed to move component", "errorDetails": str(e)} + + def rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Rotate an existing component""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + reference = params.get("reference") + angle = params.get("angle") + + if not reference or angle is None: + return { + "success": False, + "message": "Missing parameters", + "errorDetails": "reference and angle are required", + } + + # Find the component + module = self.board.FindFootprintByReference(reference) + if not module: + return { + "success": False, + "message": "Component not found", + "errorDetails": f"Could not find component: {reference}", + } + + # Set rotation + rotation_angle = pcbnew.EDA_ANGLE(angle, pcbnew.DEGREES_T) + module.SetOrientation(rotation_angle) + + return { + "success": True, + "message": f"Rotated component: {reference}", + "component": {"reference": reference, "rotation": angle}, + } + + except Exception as e: + logger.error(f"Error rotating component: {str(e)}") + return { + "success": False, + "message": "Failed to rotate component", + "errorDetails": str(e), + } + + def delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Delete a component from the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + reference = params.get("reference") + if not reference: + return { + "success": False, + "message": "Missing reference", + "errorDetails": "reference parameter is required", + } + + # Find the component + module = self.board.FindFootprintByReference(reference) + if not module: + return { + "success": False, + "message": "Component not found", + "errorDetails": f"Could not find component: {reference}", + } + + # Remove from board + self.board.Remove(module) + + return {"success": True, "message": f"Deleted component: {reference}"} + + except Exception as e: + logger.error(f"Error deleting component: {str(e)}") + return { + "success": False, + "message": "Failed to delete component", + "errorDetails": str(e), + } + + def edit_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Edit the properties of an existing component""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + reference = params.get("reference") + new_reference = params.get("newReference") + value = params.get("value") + footprint = params.get("footprint") + + if not reference: + return { + "success": False, + "message": "Missing reference", + "errorDetails": "reference parameter is required", + } + + # Find the component + module = self.board.FindFootprintByReference(reference) + if not module: + return { + "success": False, + "message": "Component not found", + "errorDetails": f"Could not find component: {reference}", + } + + # Update properties + if new_reference: + module.SetReference(new_reference) + if value: + module.SetValue(value) + if footprint: + # For KiCAD 9.x compatibility, use SetFPID instead of SetFootprintName + # Parse footprint string (format: "Library:Footprint") + if ":" in footprint: + lib_name, fp_name = footprint.split(":", 1) + fpid = pcbnew.LIB_ID(lib_name, fp_name) + module.SetFPID(fpid) + else: + # If no library specified, keep existing library + current_fpid = module.GetFPID() + lib_name = current_fpid.GetLibNickname().GetUTF8() + fpid = pcbnew.LIB_ID(lib_name, footprint) + module.SetFPID(fpid) + + return { + "success": True, + "message": f"Updated component: {reference}", + "component": { + "reference": new_reference or reference, + "value": value or module.GetValue(), + "footprint": footprint or module.GetFPIDAsString(), + }, + } + + except Exception as e: + logger.error(f"Error editing component: {str(e)}") + return {"success": False, "message": "Failed to edit component", "errorDetails": str(e)} + + def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get detailed properties of a component""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + reference = params.get("reference") + if not reference: + return { + "success": False, + "message": "Missing reference", + "errorDetails": "reference parameter is required", + } + + # Find the component + module = self.board.FindFootprintByReference(reference) + if not module: + return { + "success": False, + "message": "Component not found", + "errorDetails": f"Could not find component: {reference}", + } + + # Get position in mm + pos = module.GetPosition() + x_mm = pos.x / 1000000 + y_mm = pos.y / 1000000 + + return { + "success": True, + "component": { + "reference": module.GetReference(), + "value": module.GetValue(), + "footprint": module.GetFPIDAsString(), + "position": {"x": x_mm, "y": y_mm, "unit": "mm"}, + "rotation": module.GetOrientation().AsDegrees(), + "layer": self.board.GetLayerName(module.GetLayer()), + "attributes": { + "smd": module.GetAttributes() & pcbnew.FP_SMD, + "through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE, + "board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY, + }, + }, + } + + except Exception as e: + logger.error(f"Error getting component properties: {str(e)}") + return { + "success": False, + "message": "Failed to get component properties", + "errorDetails": str(e), + } + + def get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get a list of all components on the board""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + components = [] + for module in self.board.GetFootprints(): + pos = module.GetPosition() + x_mm = pos.x / 1000000 + y_mm = pos.y / 1000000 + + components.append( + { + "reference": module.GetReference(), + "value": module.GetValue(), + "footprint": module.GetFPIDAsString(), + "position": {"x": x_mm, "y": y_mm, "unit": "mm"}, + "rotation": module.GetOrientation().AsDegrees(), + "layer": self.board.GetLayerName(module.GetLayer()), + } + ) + + return {"success": True, "components": components} + + except Exception as e: + logger.error(f"Error getting component list: {str(e)}") + return { + "success": False, + "message": "Failed to get component list", + "errorDetails": str(e), + } + + def find_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Find components matching search criteria (reference, value, or footprint pattern)""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + # Get search parameters + reference_pattern = params.get("reference", "").lower() + value_pattern = params.get("value", "").lower() + footprint_pattern = params.get("footprint", "").lower() + + if not reference_pattern and not value_pattern and not footprint_pattern: + return { + "success": False, + "message": "Missing search criteria", + "errorDetails": "At least one of reference, value, or footprint pattern is required", + } + + matches = [] + for module in self.board.GetFootprints(): + ref = module.GetReference().lower() + val = module.GetValue().lower() + fp = module.GetFPIDAsString().lower() + + # Check if component matches all provided patterns + match = True + if reference_pattern and reference_pattern not in ref: + match = False + if value_pattern and value_pattern not in val: + match = False + if footprint_pattern and footprint_pattern not in fp: + match = False + + if match: + pos = module.GetPosition() + matches.append( + { + "reference": module.GetReference(), + "value": module.GetValue(), + "footprint": module.GetFPIDAsString(), + "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, + "rotation": module.GetOrientation().AsDegrees(), + "layer": self.board.GetLayerName(module.GetLayer()), + } + ) + + return {"success": True, "matchCount": len(matches), "components": matches} + + except Exception as e: + logger.error(f"Error finding components: {str(e)}") + return { + "success": False, + "message": "Failed to find components", + "errorDetails": str(e), + } + + def get_component_pads(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get all pads for a component with their positions and net connections""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + reference = params.get("reference") + if not reference: + return { + "success": False, + "message": "Missing reference", + "errorDetails": "reference parameter is required", + } + + # Find the component + module = self.board.FindFootprintByReference(reference) + if not module: + return { + "success": False, + "message": "Component not found", + "errorDetails": f"Could not find component: {reference}", + } + + pads = [] + for pad in module.Pads(): + pos = pad.GetPosition() + size = pad.GetSize() + + # Get pad shape as string + shape_map = { + pcbnew.PAD_SHAPE_CIRCLE: "circle", + pcbnew.PAD_SHAPE_RECT: "rect", + pcbnew.PAD_SHAPE_OVAL: "oval", + pcbnew.PAD_SHAPE_TRAPEZOID: "trapezoid", + pcbnew.PAD_SHAPE_ROUNDRECT: "roundrect", + pcbnew.PAD_SHAPE_CHAMFERED_RECT: "chamfered_rect", + pcbnew.PAD_SHAPE_CUSTOM: "custom", + } + shape = shape_map.get(pad.GetShape(), "unknown") + + # Get pad type + type_map = { + pcbnew.PAD_ATTRIB_PTH: "through_hole", + pcbnew.PAD_ATTRIB_SMD: "smd", + pcbnew.PAD_ATTRIB_CONN: "connector", + pcbnew.PAD_ATTRIB_NPTH: "npth", + } + pad_type = type_map.get(pad.GetAttribute(), "unknown") + + pads.append( + { + "name": pad.GetName(), + "number": pad.GetNumber(), + "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, + "net": pad.GetNetname(), + "netCode": pad.GetNetCode(), + "shape": shape, + "type": pad_type, + "size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"}, + "drillSize": ( + pad.GetDrillSize().x / 1000000 if pad.GetDrillSize().x > 0 else None + ), + } + ) + + # Get component position for reference + comp_pos = module.GetPosition() + + return { + "success": True, + "reference": reference, + "componentPosition": { + "x": comp_pos.x / 1000000, + "y": comp_pos.y / 1000000, + "unit": "mm", + }, + "padCount": len(pads), + "pads": pads, + } + + except Exception as e: + logger.error(f"Error getting component pads: {str(e)}") + return { + "success": False, + "message": "Failed to get component pads", + "errorDetails": str(e), + } + + def get_pad_position(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get the position of a specific pad on a component""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + reference = params.get("reference") + pad_name = params.get("padName") or params.get("padNumber") + + if not reference: + return { + "success": False, + "message": "Missing reference", + "errorDetails": "reference parameter is required", + } + if not pad_name: + return { + "success": False, + "message": "Missing pad identifier", + "errorDetails": "padName or padNumber parameter is required", + } + + # Find the component + module = self.board.FindFootprintByReference(reference) + if not module: + return { + "success": False, + "message": "Component not found", + "errorDetails": f"Could not find component: {reference}", + } + + # Find the specific pad + pad = module.FindPadByNumber(str(pad_name)) + if not pad: + # List available pads in error message + available_pads = [p.GetNumber() for p in module.Pads()] + return { + "success": False, + "message": "Pad not found", + "errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}", + } + + pos = pad.GetPosition() + size = pad.GetSize() + + return { + "success": True, + "reference": reference, + "padName": pad.GetNumber(), + "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, + "net": pad.GetNetname(), + "netCode": pad.GetNetCode(), + "size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"}, + } + + except Exception as e: + logger.error(f"Error getting pad position: {str(e)}") + return { + "success": False, + "message": "Failed to get pad position", + "errorDetails": str(e), + } + + def place_component_array(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Place an array of components in a grid or circular pattern""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + component_id = params.get("componentId") + pattern = params.get("pattern", "grid") # grid or circular + count = params.get("count") + reference_prefix = params.get("referencePrefix", "U") + value = params.get("value") + + if not component_id or not count: + return { + "success": False, + "message": "Missing parameters", + "errorDetails": "componentId and count are required", + } + + if pattern == "grid": + start_position = params.get("startPosition") + rows = params.get("rows") + columns = params.get("columns") + spacing_x = params.get("spacingX") + spacing_y = params.get("spacingY") + rotation = params.get("rotation", 0) + layer = params.get("layer", "F.Cu") + + if not start_position or not rows or not columns or not spacing_x or not spacing_y: + return { + "success": False, + "message": "Missing grid parameters", + "errorDetails": "For grid pattern, startPosition, rows, columns, spacingX, and spacingY are required", + } + + if rows * columns != count: + return { + "success": False, + "message": "Invalid grid parameters", + "errorDetails": "rows * columns must equal count", + } + + placed_components = self._place_grid_array( + component_id, + start_position, + rows, + columns, + spacing_x, + spacing_y, + reference_prefix, + value, + rotation, + layer, + ) + + elif pattern == "circular": + center = params.get("center") + radius = params.get("radius") + angle_start = params.get("angleStart", 0) + angle_step = params.get("angleStep") + rotation_offset = params.get("rotationOffset", 0) + layer = params.get("layer", "F.Cu") + + if not center or not radius or not angle_step: + return { + "success": False, + "message": "Missing circular parameters", + "errorDetails": "For circular pattern, center, radius, and angleStep are required", + } + + placed_components = self._place_circular_array( + component_id, + center, + radius, + count, + angle_start, + angle_step, + reference_prefix, + value, + rotation_offset, + layer, + ) + + else: + return { + "success": False, + "message": "Invalid pattern", + "errorDetails": "Pattern must be 'grid' or 'circular'", + } + + return { + "success": True, + "message": f"Placed {count} components in {pattern} pattern", + "components": placed_components, + } + + except Exception as e: + logger.error(f"Error placing component array: {str(e)}") + return { + "success": False, + "message": "Failed to place component array", + "errorDetails": str(e), + } + + def align_components(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Align multiple components along a line or distribute them evenly""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + references = params.get("references", []) + alignment = params.get("alignment", "horizontal") # horizontal, vertical, or edge + distribution = params.get("distribution", "none") # none, equal, or spacing + spacing = params.get("spacing") + + if not references or len(references) < 2: + return { + "success": False, + "message": "Missing references", + "errorDetails": "At least two component references are required", + } + + # Find all referenced components + components = [] + for ref in references: + module = self.board.FindFootprintByReference(ref) + if not module: + return { + "success": False, + "message": "Component not found", + "errorDetails": f"Could not find component: {ref}", + } + components.append(module) + + # Perform alignment based on selected option + if alignment == "horizontal": + self._align_components_horizontally(components, distribution, spacing) + elif alignment == "vertical": + self._align_components_vertically(components, distribution, spacing) + elif alignment == "edge": + edge = params.get("edge") + if not edge: + return { + "success": False, + "message": "Missing edge parameter", + "errorDetails": "Edge parameter is required for edge alignment", + } + self._align_components_to_edge(components, edge) + else: + return { + "success": False, + "message": "Invalid alignment option", + "errorDetails": "Alignment must be 'horizontal', 'vertical', or 'edge'", + } + + # Prepare result data + aligned_components = [] + for module in components: + pos = module.GetPosition() + aligned_components.append( + { + "reference": module.GetReference(), + "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, + "rotation": module.GetOrientation().AsDegrees(), + } + ) + + return { + "success": True, + "message": f"Aligned {len(components)} components", + "alignment": alignment, + "distribution": distribution, + "components": aligned_components, + } + + except Exception as e: + logger.error(f"Error aligning components: {str(e)}") + return { + "success": False, + "message": "Failed to align components", + "errorDetails": str(e), + } + + def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Duplicate an existing component""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + reference = params.get("reference") + new_reference = params.get("newReference") + position = params.get("position") + rotation = params.get("rotation") + + if not reference or not new_reference: + return { + "success": False, + "message": "Missing parameters", + "errorDetails": "reference and newReference are required", + } + + # Find the source component + source = self.board.FindFootprintByReference(reference) + if not source: + return { + "success": False, + "message": "Component not found", + "errorDetails": f"Could not find component: {reference}", + } + + # Check if new reference already exists + if self.board.FindFootprintByReference(new_reference): + return { + "success": False, + "message": "Reference already exists", + "errorDetails": f"A component with reference {new_reference} already exists", + } + + # Create new footprint with the same properties + new_module = pcbnew.FOOTPRINT(self.board) + # For KiCAD 9.x compatibility, use SetFPID instead of SetFootprintName + new_module.SetFPID(source.GetFPID()) + new_module.SetValue(source.GetValue()) + new_module.SetReference(new_reference) + new_module.SetLayer(source.GetLayer()) + + # Copy pads and other items + for pad in source.Pads(): + new_pad = pcbnew.PAD(new_module) + new_pad.Copy(pad) + new_module.Add(new_pad) + + # Set position if provided, otherwise use offset from original + if position: + scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 + x_nm = int(position["x"] * scale) + y_nm = int(position["y"] * scale) + new_module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) + else: + # Offset by 5mm + source_pos = source.GetPosition() + new_module.SetPosition(pcbnew.VECTOR2I(source_pos.x + 5000000, source_pos.y)) + + # Set rotation if provided, otherwise use same as original + if rotation is not None: + rotation_angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T) + new_module.SetOrientation(rotation_angle) + else: + new_module.SetOrientation(source.GetOrientation()) + + # Add to board + self.board.Add(new_module) + + # Get final position in mm + pos = new_module.GetPosition() + + return { + "success": True, + "message": f"Duplicated component {reference} to {new_reference}", + "component": { + "reference": new_reference, + "value": new_module.GetValue(), + "footprint": new_module.GetFPIDAsString(), + "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"}, + "rotation": new_module.GetOrientation().AsDegrees(), + "layer": self.board.GetLayerName(new_module.GetLayer()), + }, + } + + except Exception as e: + logger.error(f"Error duplicating component: {str(e)}") + return { + "success": False, + "message": "Failed to duplicate component", + "errorDetails": str(e), + } + + def _place_grid_array( + self, + component_id: str, + start_position: Dict[str, Any], + rows: int, + columns: int, + spacing_x: float, + spacing_y: float, + reference_prefix: str, + value: str, + rotation: float, + layer: str, + ) -> List[Dict[str, Any]]: + """Place components in a grid pattern and return the list of placed components""" + placed = [] + + # Convert spacing to nm + unit = start_position.get("unit", "mm") + scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm + spacing_x_nm = int(spacing_x * scale) + spacing_y_nm = int(spacing_y * scale) + + # Get layer ID + layer_id = self.board.GetLayerID(layer) + + for row in range(rows): + for col in range(columns): + # Calculate position + x = start_position["x"] + (col * spacing_x) + y = start_position["y"] + (row * spacing_y) + + # Generate reference + index = row * columns + col + 1 + component_reference = f"{reference_prefix}{index}" + + # Place component + result = self.place_component( + { + "componentId": component_id, + "position": {"x": x, "y": y, "unit": unit}, + "reference": component_reference, + "value": value, + "rotation": rotation, + "layer": layer, + } + ) + + if result["success"]: + placed.append(result["component"]) + + return placed + + def _place_circular_array( + self, + component_id: str, + center: Dict[str, Any], + radius: float, + count: int, + angle_start: float, + angle_step: float, + reference_prefix: str, + value: str, + rotation_offset: float, + layer: str, + ) -> List[Dict[str, Any]]: + """Place components in a circular pattern and return the list of placed components""" + placed = [] + + # Get unit + unit = center.get("unit", "mm") + + for i in range(count): + # Calculate angle for this component + angle = angle_start + (i * angle_step) + angle_rad = math.radians(angle) + + # Calculate position + x = center["x"] + (radius * math.cos(angle_rad)) + y = center["y"] + (radius * math.sin(angle_rad)) + + # Generate reference + component_reference = f"{reference_prefix}{i+1}" + + # Calculate rotation (pointing outward from center) + component_rotation = angle + rotation_offset + + # Place component + result = self.place_component( + { + "componentId": component_id, + "position": {"x": x, "y": y, "unit": unit}, + "reference": component_reference, + "value": value, + "rotation": component_rotation, + "layer": layer, + } + ) + + if result["success"]: + placed.append(result["component"]) + + return placed + + def _align_components_horizontally( + self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float] + ) -> None: + """Align components horizontally and optionally distribute them""" + if not components: + return + + # Find the average Y coordinate + y_sum = sum(module.GetPosition().y for module in components) + y_avg = y_sum // len(components) + + # Sort components by X position + components.sort(key=lambda m: m.GetPosition().x) + + # Set Y coordinate for all components + for module in components: + pos = module.GetPosition() + module.SetPosition(pcbnew.VECTOR2I(pos.x, y_avg)) + + # Handle distribution if requested + if distribution == "equal" and len(components) > 1: + # Get leftmost and rightmost X coordinates + x_min = components[0].GetPosition().x + x_max = components[-1].GetPosition().x + + # Calculate equal spacing + total_space = x_max - x_min + spacing_nm = total_space // (len(components) - 1) + + # Set X positions with equal spacing + for i in range(1, len(components) - 1): + pos = components[i].GetPosition() + new_x = x_min + (i * spacing_nm) + components[i].SetPosition(pcbnew.VECTOR2I(new_x, pos.y)) + + elif distribution == "spacing" and spacing is not None: + # Convert spacing to nanometers + spacing_nm = int(spacing * 1000000) # assuming mm + + # Set X positions with the specified spacing + x_current = components[0].GetPosition().x + for i in range(1, len(components)): + pos = components[i].GetPosition() + x_current += spacing_nm + components[i].SetPosition(pcbnew.VECTOR2I(x_current, pos.y)) + + def _align_components_vertically( + self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float] + ) -> None: + """Align components vertically and optionally distribute them""" + if not components: + return + + # Find the average X coordinate + x_sum = sum(module.GetPosition().x for module in components) + x_avg = x_sum // len(components) + + # Sort components by Y position + components.sort(key=lambda m: m.GetPosition().y) + + # Set X coordinate for all components + for module in components: + pos = module.GetPosition() + module.SetPosition(pcbnew.VECTOR2I(x_avg, pos.y)) + + # Handle distribution if requested + if distribution == "equal" and len(components) > 1: + # Get topmost and bottommost Y coordinates + y_min = components[0].GetPosition().y + y_max = components[-1].GetPosition().y + + # Calculate equal spacing + total_space = y_max - y_min + spacing_nm = total_space // (len(components) - 1) + + # Set Y positions with equal spacing + for i in range(1, len(components) - 1): + pos = components[i].GetPosition() + new_y = y_min + (i * spacing_nm) + components[i].SetPosition(pcbnew.VECTOR2I(pos.x, new_y)) + + elif distribution == "spacing" and spacing is not None: + # Convert spacing to nanometers + spacing_nm = int(spacing * 1000000) # assuming mm + + # Set Y positions with the specified spacing + y_current = components[0].GetPosition().y + for i in range(1, len(components)): + pos = components[i].GetPosition() + y_current += spacing_nm + components[i].SetPosition(pcbnew.VECTOR2I(pos.x, y_current)) + + def _align_components_to_edge(self, components: List[pcbnew.FOOTPRINT], edge: str) -> None: + """Align components to the specified edge of the board""" + if not components: + return + + # Get board bounds + board_box = self.board.GetBoardEdgesBoundingBox() + left = board_box.GetLeft() + right = board_box.GetRight() + top = board_box.GetTop() + bottom = board_box.GetBottom() + + # Align based on specified edge + if edge == "left": + for module in components: + pos = module.GetPosition() + module.SetPosition(pcbnew.VECTOR2I(left + 2000000, pos.y)) # 2mm offset from edge + elif edge == "right": + for module in components: + pos = module.GetPosition() + module.SetPosition(pcbnew.VECTOR2I(right - 2000000, pos.y)) # 2mm offset from edge + elif edge == "top": + for module in components: + pos = module.GetPosition() + module.SetPosition(pcbnew.VECTOR2I(pos.x, top + 2000000)) # 2mm offset from edge + elif edge == "bottom": + for module in components: + pos = module.GetPosition() + module.SetPosition(pcbnew.VECTOR2I(pos.x, bottom - 2000000)) # 2mm offset from edge + else: + logger.warning(f"Unknown edge alignment: {edge}") diff --git a/python/commands/component_schematic.py b/python/commands/component_schematic.py index c280919..6236906 100644 --- a/python/commands/component_schematic.py +++ b/python/commands/component_schematic.py @@ -1,410 +1,410 @@ -import logging -import os -import uuid -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -from skip import Schematic - -logger = logging.getLogger(__name__) - -# Import dynamic symbol loader -try: - from commands.dynamic_symbol_loader import DynamicSymbolLoader - - DYNAMIC_LOADING_AVAILABLE = True -except ImportError: - logger.warning("Dynamic symbol loader not available - falling back to template-only mode") - DYNAMIC_LOADING_AVAILABLE = False - - -class ComponentManager: - """Manage components in a schematic""" - - # Initialize dynamic loader (class variable, shared across instances) - _dynamic_loader = None - - @classmethod - def get_dynamic_loader(cls) -> Any: - """Get or create dynamic symbol loader instance""" - if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE: - cls._dynamic_loader = DynamicSymbolLoader() - return cls._dynamic_loader - - # Template symbol references mapping component type to template reference - TEMPLATE_MAP = { - # Passives - "R": "_TEMPLATE_R", - "C": "_TEMPLATE_C", - "L": "_TEMPLATE_L", - "Y": "_TEMPLATE_Y", - "Crystal": "_TEMPLATE_Y", - # Semiconductors - "D": "_TEMPLATE_D", - "LED": "_TEMPLATE_LED", - "Q": "_TEMPLATE_Q_NPN", - "Q_NPN": "_TEMPLATE_Q_NPN", - "Q_NMOS": "_TEMPLATE_Q_NMOS", - "MOSFET": "_TEMPLATE_Q_NMOS", - # ICs - "U": "_TEMPLATE_U_OPAMP", - "OpAmp": "_TEMPLATE_U_OPAMP", - "IC": "_TEMPLATE_U_OPAMP", - "U_REG": "_TEMPLATE_U_REG", - "Regulator": "_TEMPLATE_U_REG", - # Connectors - "J": "_TEMPLATE_J2", - "J2": "_TEMPLATE_J2", - "J4": "_TEMPLATE_J4", - "Conn_2": "_TEMPLATE_J2", - "Conn_4": "_TEMPLATE_J4", - # Misc - "SW": "_TEMPLATE_SW", - "Button": "_TEMPLATE_SW", - "Switch": "_TEMPLATE_SW", - } - - @classmethod - def get_or_create_template( - cls, - schematic: Schematic, - comp_type: str, - library: Optional[str] = None, - schematic_path: Optional[Path] = None, - ) -> tuple: - """ - Get template reference for a component type, creating it dynamically if needed - - Args: - schematic: Schematic object - comp_type: Component type (e.g., 'R', 'LED', 'STM32F103C8Tx') - library: Optional library name (defaults to 'Device' for common types) - schematic_path: Optional path to schematic file (required for dynamic loading) - - Returns: - Tuple of (template_ref, needs_reload) where needs_reload indicates if schematic must be reloaded - """ - - # Helper function to check if template exists in schematic - def template_exists(schematic: Any, template_ref: str) -> bool: - """Check if template exists by iterating symbols (handles special characters)""" - for symbol in schematic.symbol: - if ( - hasattr(symbol.property, "Reference") - and symbol.property.Reference.value == template_ref - ): - return True - return False - - # 1. Check static template map first - if comp_type in cls.TEMPLATE_MAP: - template_ref = cls.TEMPLATE_MAP[comp_type] - # Verify template exists in schematic - if template_exists(schematic, template_ref): - logger.debug(f"Using static template: {template_ref}") - return (template_ref, False) - - # 2. Check if dynamically loaded template already exists - # Build potential template reference names - potential_refs = [] - if library: - potential_refs.append(f"_TEMPLATE_{library}_{comp_type}") - potential_refs.append(f"_TEMPLATE_{comp_type}") - if comp_type in cls.TEMPLATE_MAP: - potential_refs.append(cls.TEMPLATE_MAP[comp_type]) - - # Check each potential reference - for template_ref in potential_refs: - if template_exists(schematic, template_ref): - logger.debug(f"Found existing template: {template_ref}") - return (template_ref, False) - - # 3. Try dynamic loading - if not DYNAMIC_LOADING_AVAILABLE: - logger.warning( - f"Component type '{comp_type}' not in static templates and dynamic loading unavailable" - ) - # Fall back to basic resistor template - return ("_TEMPLATE_R", False) - - loader = cls.get_dynamic_loader() - if not loader: - logger.warning("Dynamic loader unavailable, using fallback template") - return ("_TEMPLATE_R", False) - - # Check if schematic path is available - if schematic_path is None: - logger.warning("Dynamic loading requires schematic file path but none was provided") - fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R") - return (fallback, False) - - # Determine library name - if library is None: - # Default library for common component types - library = "Device" # Most passives and basic components are in Device library - - try: - logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}") - - # Use dynamic symbol loader to inject symbol and create template - template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type) - - logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}") - # Signal that schematic needs reload to see new template - return (template_ref, True) - - except Exception as e: - logger.error(f"Dynamic loading failed: {e}") - import traceback - - logger.error(traceback.format_exc()) - # Fall back to static template if available - fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R") - return (fallback, False) - - @staticmethod - def add_component( - schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None - ) -> Any: - """ - Add a component to the schematic by cloning from template - - Args: - schematic: Schematic object to add component to - component_def: Component definition dictionary - schematic_path: Optional path to schematic file (enables dynamic symbol loading) - - Returns: - Tuple of (new_symbol, needs_reload) where needs_reload indicates if caller should reload schematic - """ - try: - from commands.schematic import SchematicManager - - logger.info( - f"Adding component: type={component_def.get('type')}, ref={component_def.get('reference')}" - ) - logger.debug(f"Full component_def: {component_def}") - - # Get component type and determine template - comp_type = component_def.get("type", "R") - library = component_def.get("library", None) # Optional library specification - - # Get template reference (static or dynamic) - template_ref, needs_reload = ComponentManager.get_or_create_template( - schematic, comp_type, library, schematic_path - ) - - # If dynamic loading occurred, reload schematic to see new template - if needs_reload and schematic_path: - logger.info(f"Reloading schematic after dynamic loading: {schematic_path}") - schematic = SchematicManager.load_schematic(str(schematic_path)) - - # Find template symbol by reference (handles special characters like +) - template_symbol = None - for symbol in schematic.symbol: - if ( - hasattr(symbol.property, "Reference") - and symbol.property.Reference.value == template_ref - ): - template_symbol = symbol - break - - if not template_symbol: - logger.error( - f"Template symbol {template_ref} not found in schematic. Available symbols: {[str(s.property.Reference.value) for s in schematic.symbol]}" - ) - raise ValueError( - f"Template symbol {template_ref} not found. The schematic must be created from template_with_symbols.kicad_sch" - ) - - # Clone the template symbol - new_symbol = template_symbol.clone() - logger.debug(f"Cloned template symbol {template_ref}") - - # Set reference - reference = component_def.get("reference", "R?") - new_symbol.property.Reference.value = reference - logger.debug(f"Set reference to {reference}") - - # Set value - if "value" in component_def: - new_symbol.property.Value.value = component_def["value"] - logger.debug(f"Set value to {component_def['value']}") - - # Set footprint - if "footprint" in component_def: - new_symbol.property.Footprint.value = component_def["footprint"] - logger.debug(f"Set footprint to {component_def['footprint']}") - - # Set datasheet - if "datasheet" in component_def: - new_symbol.property.Datasheet.value = component_def["datasheet"] - - # Set position - x = component_def.get("x", 0) - y = component_def.get("y", 0) - rotation = component_def.get("rotation", 0) - new_symbol.at.value = [x, y, rotation] - logger.debug(f"Set position to ({x}, {y}, {rotation})") - - # Set BOM and board flags - new_symbol.in_bom.value = component_def.get("in_bom", True) - new_symbol.on_board.value = component_def.get("on_board", True) - new_symbol.dnp.value = component_def.get("dnp", False) - - # Generate new UUID - new_symbol.uuid.value = str(uuid.uuid4()) - - # Append to schematic - schematic.symbol.append(new_symbol) - logger.info(f"Successfully added component {reference} to schematic") - - return new_symbol - except Exception as e: - logger.error(f"Error adding component: {e}", exc_info=True) - raise - - @staticmethod - def remove_component(schematic: Schematic, component_ref: str) -> bool: - """Remove a component from the schematic by reference designator""" - try: - # kicad-skip doesn't have a direct remove_symbol method by reference. - # We need to find the symbol and then remove it from the symbols list. - symbol_to_remove = None - for symbol in schematic.symbol: - if symbol.reference == component_ref: - symbol_to_remove = symbol - break - - if symbol_to_remove: - schematic.symbol._elements.remove(symbol_to_remove) - logger.info(f"Removed component {component_ref} from schematic.") - return True - else: - logger.warning(f"Component with reference {component_ref} not found.") - return False - except Exception as e: - logger.error(f"Error removing component {component_ref}: {e}") - return False - - @staticmethod - def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool: - """Update component properties by reference designator""" - try: - symbol_to_update = None - for symbol in schematic.symbol: - if symbol.reference == component_ref: - symbol_to_update = symbol - break - - if symbol_to_update: - for key, value in new_properties.items(): - if key in symbol_to_update.property: - symbol_to_update.property[key].value = value - else: - symbol_to_update.property.append(key, value) - logger.info(f"Updated properties for component {component_ref}.") - return True - else: - logger.warning(f"Component with reference {component_ref} not found.") - return False - except Exception as e: - logger.error(f"Error updating component {component_ref}: {e}") - return False - - @staticmethod - def get_component(schematic: Schematic, component_ref: str) -> Any: - """Get a component by reference designator""" - for symbol in schematic.symbol: - if symbol.reference == component_ref: - logger.debug(f"Found component with reference {component_ref}.") - return symbol - logger.warning(f"Component with reference {component_ref} not found.") - return None - - @staticmethod - def search_components(schematic: Schematic, query: str) -> List[Any]: - """Search for components matching criteria (basic implementation)""" - # This is a basic search, could be expanded to use regex or more complex logic - matching_components = [] - query_lower = query.lower() - for symbol in schematic.symbol: - if ( - query_lower in symbol.reference.lower() - or query_lower in symbol.name.lower() - or ( - hasattr(symbol.property, "Value") - and query_lower in symbol.property.Value.value.lower() - ) - ): - matching_components.append(symbol) - logger.debug(f"Found {len(matching_components)} components matching query '{query}'.") - return matching_components - - @staticmethod - def get_all_components(schematic: Schematic) -> List[Any]: - """Get all components in schematic""" - logger.debug(f"Retrieving all {len(schematic.symbol)} components.") - return list(schematic.symbol) - - -if __name__ == "__main__": - # Example Usage (for testing) - from schematic import ( # Assuming schematic.py is in the same directory - SchematicManager, - ) - - # Create a new schematic - test_sch = SchematicManager.create_schematic("ComponentTestSchematic") - - # Add components - comp1_def = {"type": "R", "reference": "R1", "value": "10k", "x": 100, "y": 100} - comp2_def = { - "type": "C", - "reference": "C1", - "value": "0.1uF", - "x": 200, - "y": 100, - "library": "Device", - } - comp3_def = { - "type": "LED", - "reference": "D1", - "x": 300, - "y": 100, - "library": "Device", - "properties": {"Color": "Red"}, - } - - comp1 = ComponentManager.add_component(test_sch, comp1_def) - comp2 = ComponentManager.add_component(test_sch, comp2_def) - comp3 = ComponentManager.add_component(test_sch, comp3_def) - - # Get a component - retrieved_comp = ComponentManager.get_component(test_sch, "C1") - if retrieved_comp: - print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})") - - # Update a component - ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"}) - - # Search components - matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position - print(f"Search results for '100': {[c.reference for c in matching_comps]}") - - # Get all components - all_comps = ComponentManager.get_all_components(test_sch) - print(f"All components: {[c.reference for c in all_comps]}") - - # Remove a component - ComponentManager.remove_component(test_sch, "D1") - all_comps_after_remove = ComponentManager.get_all_components(test_sch) - print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}") - - # Save the schematic (optional) - # SchematicManager.save_schematic(test_sch, "component_test.kicad_sch") - - # Clean up (if saved) - # if os.path.exists("component_test.kicad_sch"): - # os.remove("component_test.kicad_sch") - # print("Cleaned up component_test.kicad_sch") +import logging +import os +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from skip import Schematic + +logger = logging.getLogger(__name__) + +# Import dynamic symbol loader +try: + from commands.dynamic_symbol_loader import DynamicSymbolLoader + + DYNAMIC_LOADING_AVAILABLE = True +except ImportError: + logger.warning("Dynamic symbol loader not available - falling back to template-only mode") + DYNAMIC_LOADING_AVAILABLE = False + + +class ComponentManager: + """Manage components in a schematic""" + + # Initialize dynamic loader (class variable, shared across instances) + _dynamic_loader = None + + @classmethod + def get_dynamic_loader(cls) -> Any: + """Get or create dynamic symbol loader instance""" + if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE: + cls._dynamic_loader = DynamicSymbolLoader() + return cls._dynamic_loader + + # Template symbol references mapping component type to template reference + TEMPLATE_MAP = { + # Passives + "R": "_TEMPLATE_R", + "C": "_TEMPLATE_C", + "L": "_TEMPLATE_L", + "Y": "_TEMPLATE_Y", + "Crystal": "_TEMPLATE_Y", + # Semiconductors + "D": "_TEMPLATE_D", + "LED": "_TEMPLATE_LED", + "Q": "_TEMPLATE_Q_NPN", + "Q_NPN": "_TEMPLATE_Q_NPN", + "Q_NMOS": "_TEMPLATE_Q_NMOS", + "MOSFET": "_TEMPLATE_Q_NMOS", + # ICs + "U": "_TEMPLATE_U_OPAMP", + "OpAmp": "_TEMPLATE_U_OPAMP", + "IC": "_TEMPLATE_U_OPAMP", + "U_REG": "_TEMPLATE_U_REG", + "Regulator": "_TEMPLATE_U_REG", + # Connectors + "J": "_TEMPLATE_J2", + "J2": "_TEMPLATE_J2", + "J4": "_TEMPLATE_J4", + "Conn_2": "_TEMPLATE_J2", + "Conn_4": "_TEMPLATE_J4", + # Misc + "SW": "_TEMPLATE_SW", + "Button": "_TEMPLATE_SW", + "Switch": "_TEMPLATE_SW", + } + + @classmethod + def get_or_create_template( + cls, + schematic: Schematic, + comp_type: str, + library: Optional[str] = None, + schematic_path: Optional[Path] = None, + ) -> tuple: + """ + Get template reference for a component type, creating it dynamically if needed + + Args: + schematic: Schematic object + comp_type: Component type (e.g., 'R', 'LED', 'STM32F103C8Tx') + library: Optional library name (defaults to 'Device' for common types) + schematic_path: Optional path to schematic file (required for dynamic loading) + + Returns: + Tuple of (template_ref, needs_reload) where needs_reload indicates if schematic must be reloaded + """ + + # Helper function to check if template exists in schematic + def template_exists(schematic: Any, template_ref: str) -> bool: + """Check if template exists by iterating symbols (handles special characters)""" + for symbol in schematic.symbol: + if ( + hasattr(symbol.property, "Reference") + and symbol.property.Reference.value == template_ref + ): + return True + return False + + # 1. Check static template map first + if comp_type in cls.TEMPLATE_MAP: + template_ref = cls.TEMPLATE_MAP[comp_type] + # Verify template exists in schematic + if template_exists(schematic, template_ref): + logger.debug(f"Using static template: {template_ref}") + return (template_ref, False) + + # 2. Check if dynamically loaded template already exists + # Build potential template reference names + potential_refs = [] + if library: + potential_refs.append(f"_TEMPLATE_{library}_{comp_type}") + potential_refs.append(f"_TEMPLATE_{comp_type}") + if comp_type in cls.TEMPLATE_MAP: + potential_refs.append(cls.TEMPLATE_MAP[comp_type]) + + # Check each potential reference + for template_ref in potential_refs: + if template_exists(schematic, template_ref): + logger.debug(f"Found existing template: {template_ref}") + return (template_ref, False) + + # 3. Try dynamic loading + if not DYNAMIC_LOADING_AVAILABLE: + logger.warning( + f"Component type '{comp_type}' not in static templates and dynamic loading unavailable" + ) + # Fall back to basic resistor template + return ("_TEMPLATE_R", False) + + loader = cls.get_dynamic_loader() + if not loader: + logger.warning("Dynamic loader unavailable, using fallback template") + return ("_TEMPLATE_R", False) + + # Check if schematic path is available + if schematic_path is None: + logger.warning("Dynamic loading requires schematic file path but none was provided") + fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R") + return (fallback, False) + + # Determine library name + if library is None: + # Default library for common component types + library = "Device" # Most passives and basic components are in Device library + + try: + logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}") + + # Use dynamic symbol loader to inject symbol and create template + template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type) + + logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}") + # Signal that schematic needs reload to see new template + return (template_ref, True) + + except Exception as e: + logger.error(f"Dynamic loading failed: {e}") + import traceback + + logger.error(traceback.format_exc()) + # Fall back to static template if available + fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R") + return (fallback, False) + + @staticmethod + def add_component( + schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None + ) -> Any: + """ + Add a component to the schematic by cloning from template + + Args: + schematic: Schematic object to add component to + component_def: Component definition dictionary + schematic_path: Optional path to schematic file (enables dynamic symbol loading) + + Returns: + Tuple of (new_symbol, needs_reload) where needs_reload indicates if caller should reload schematic + """ + try: + from commands.schematic import SchematicManager + + logger.info( + f"Adding component: type={component_def.get('type')}, ref={component_def.get('reference')}" + ) + logger.debug(f"Full component_def: {component_def}") + + # Get component type and determine template + comp_type = component_def.get("type", "R") + library = component_def.get("library", None) # Optional library specification + + # Get template reference (static or dynamic) + template_ref, needs_reload = ComponentManager.get_or_create_template( + schematic, comp_type, library, schematic_path + ) + + # If dynamic loading occurred, reload schematic to see new template + if needs_reload and schematic_path: + logger.info(f"Reloading schematic after dynamic loading: {schematic_path}") + schematic = SchematicManager.load_schematic(str(schematic_path)) + + # Find template symbol by reference (handles special characters like +) + template_symbol = None + for symbol in schematic.symbol: + if ( + hasattr(symbol.property, "Reference") + and symbol.property.Reference.value == template_ref + ): + template_symbol = symbol + break + + if not template_symbol: + logger.error( + f"Template symbol {template_ref} not found in schematic. Available symbols: {[str(s.property.Reference.value) for s in schematic.symbol]}" + ) + raise ValueError( + f"Template symbol {template_ref} not found. The schematic must be created from template_with_symbols.kicad_sch" + ) + + # Clone the template symbol + new_symbol = template_symbol.clone() + logger.debug(f"Cloned template symbol {template_ref}") + + # Set reference + reference = component_def.get("reference", "R?") + new_symbol.property.Reference.value = reference + logger.debug(f"Set reference to {reference}") + + # Set value + if "value" in component_def: + new_symbol.property.Value.value = component_def["value"] + logger.debug(f"Set value to {component_def['value']}") + + # Set footprint + if "footprint" in component_def: + new_symbol.property.Footprint.value = component_def["footprint"] + logger.debug(f"Set footprint to {component_def['footprint']}") + + # Set datasheet + if "datasheet" in component_def: + new_symbol.property.Datasheet.value = component_def["datasheet"] + + # Set position + x = component_def.get("x", 0) + y = component_def.get("y", 0) + rotation = component_def.get("rotation", 0) + new_symbol.at.value = [x, y, rotation] + logger.debug(f"Set position to ({x}, {y}, {rotation})") + + # Set BOM and board flags + new_symbol.in_bom.value = component_def.get("in_bom", True) + new_symbol.on_board.value = component_def.get("on_board", True) + new_symbol.dnp.value = component_def.get("dnp", False) + + # Generate new UUID + new_symbol.uuid.value = str(uuid.uuid4()) + + # Append to schematic + schematic.symbol.append(new_symbol) + logger.info(f"Successfully added component {reference} to schematic") + + return new_symbol + except Exception as e: + logger.error(f"Error adding component: {e}", exc_info=True) + raise + + @staticmethod + def remove_component(schematic: Schematic, component_ref: str) -> bool: + """Remove a component from the schematic by reference designator""" + try: + # kicad-skip doesn't have a direct remove_symbol method by reference. + # We need to find the symbol and then remove it from the symbols list. + symbol_to_remove = None + for symbol in schematic.symbol: + if symbol.reference == component_ref: + symbol_to_remove = symbol + break + + if symbol_to_remove: + schematic.symbol._elements.remove(symbol_to_remove) + logger.info(f"Removed component {component_ref} from schematic.") + return True + else: + logger.warning(f"Component with reference {component_ref} not found.") + return False + except Exception as e: + logger.error(f"Error removing component {component_ref}: {e}") + return False + + @staticmethod + def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool: + """Update component properties by reference designator""" + try: + symbol_to_update = None + for symbol in schematic.symbol: + if symbol.reference == component_ref: + symbol_to_update = symbol + break + + if symbol_to_update: + for key, value in new_properties.items(): + if key in symbol_to_update.property: + symbol_to_update.property[key].value = value + else: + symbol_to_update.property.append(key, value) + logger.info(f"Updated properties for component {component_ref}.") + return True + else: + logger.warning(f"Component with reference {component_ref} not found.") + return False + except Exception as e: + logger.error(f"Error updating component {component_ref}: {e}") + return False + + @staticmethod + def get_component(schematic: Schematic, component_ref: str) -> Any: + """Get a component by reference designator""" + for symbol in schematic.symbol: + if symbol.reference == component_ref: + logger.debug(f"Found component with reference {component_ref}.") + return symbol + logger.warning(f"Component with reference {component_ref} not found.") + return None + + @staticmethod + def search_components(schematic: Schematic, query: str) -> List[Any]: + """Search for components matching criteria (basic implementation)""" + # This is a basic search, could be expanded to use regex or more complex logic + matching_components = [] + query_lower = query.lower() + for symbol in schematic.symbol: + if ( + query_lower in symbol.reference.lower() + or query_lower in symbol.name.lower() + or ( + hasattr(symbol.property, "Value") + and query_lower in symbol.property.Value.value.lower() + ) + ): + matching_components.append(symbol) + logger.debug(f"Found {len(matching_components)} components matching query '{query}'.") + return matching_components + + @staticmethod + def get_all_components(schematic: Schematic) -> List[Any]: + """Get all components in schematic""" + logger.debug(f"Retrieving all {len(schematic.symbol)} components.") + return list(schematic.symbol) + + +if __name__ == "__main__": + # Example Usage (for testing) + from schematic import ( # Assuming schematic.py is in the same directory + SchematicManager, + ) + + # Create a new schematic + test_sch = SchematicManager.create_schematic("ComponentTestSchematic") + + # Add components + comp1_def = {"type": "R", "reference": "R1", "value": "10k", "x": 100, "y": 100} + comp2_def = { + "type": "C", + "reference": "C1", + "value": "0.1uF", + "x": 200, + "y": 100, + "library": "Device", + } + comp3_def = { + "type": "LED", + "reference": "D1", + "x": 300, + "y": 100, + "library": "Device", + "properties": {"Color": "Red"}, + } + + comp1 = ComponentManager.add_component(test_sch, comp1_def) + comp2 = ComponentManager.add_component(test_sch, comp2_def) + comp3 = ComponentManager.add_component(test_sch, comp3_def) + + # Get a component + retrieved_comp = ComponentManager.get_component(test_sch, "C1") + if retrieved_comp: + print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})") + + # Update a component + ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"}) + + # Search components + matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position + print(f"Search results for '100': {[c.reference for c in matching_comps]}") + + # Get all components + all_comps = ComponentManager.get_all_components(test_sch) + print(f"All components: {[c.reference for c in all_comps]}") + + # Remove a component + ComponentManager.remove_component(test_sch, "D1") + all_comps_after_remove = ComponentManager.get_all_components(test_sch) + print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}") + + # Save the schematic (optional) + # SchematicManager.save_schematic(test_sch, "component_test.kicad_sch") + + # Clean up (if saved) + # if os.path.exists("component_test.kicad_sch"): + # os.remove("component_test.kicad_sch") + # print("Cleaned up component_test.kicad_sch") diff --git a/python/commands/design_rules.py b/python/commands/design_rules.py index b8b69e5..2c679b6 100644 --- a/python/commands/design_rules.py +++ b/python/commands/design_rules.py @@ -1,459 +1,459 @@ -""" -Design rules command implementations for KiCAD interface -""" - -import logging -import os -from typing import Any, Dict, List, Optional, Tuple - -import pcbnew - -logger = logging.getLogger("kicad_interface") - - -class DesignRuleCommands: - """Handles design rule checking and configuration""" - - def __init__(self, board: Optional[pcbnew.BOARD] = None): - """Initialize with optional board instance""" - self.board = board - - def set_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Set design rules for the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - design_settings = self.board.GetDesignSettings() - - # Convert mm to nanometers for KiCAD internal units - scale = 1000000 # mm to nm - - # Set clearance - if "clearance" in params: - design_settings.m_MinClearance = int(params["clearance"] * scale) - - # KiCAD 9.0: Use SetCustom* methods instead of SetCurrent* (which were removed) - # Track if we set any custom track/via values - custom_values_set = False - - if "trackWidth" in params: - design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale)) - custom_values_set = True - - # Via settings - if "viaDiameter" in params: - design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale)) - custom_values_set = True - if "viaDrill" in params: - design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale)) - custom_values_set = True - - # KiCAD 9.0: Activate custom track/via values so they become the current values - if custom_values_set: - design_settings.UseCustomTrackViaSize(True) - - # Set micro via settings (use properties - methods removed in KiCAD 9.0) - if "microViaDiameter" in params: - design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale) - if "microViaDrill" in params: - design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale) - - # Set minimum values - if "minTrackWidth" in params: - design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale) - if "minViaDiameter" in params: - design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale) - - # KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead - if "minViaDrill" in params: - design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale) - - if "minMicroViaDiameter" in params: - design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale) - if "minMicroViaDrill" in params: - design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale) - - # KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill - if "minHoleDiameter" in params: - design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale) - - # KiCAD 9.0: Added hole clearance settings - if "holeClearance" in params: - design_settings.m_HoleClearance = int(params["holeClearance"] * scale) - if "holeToHoleMin" in params: - design_settings.m_HoleToHoleMin = int(params["holeToHoleMin"] * scale) - - # Build response with KiCAD 9.0 compatible properties - # After UseCustomTrackViaSize(True), GetCurrent* returns the custom values - response_rules = { - "clearance": design_settings.m_MinClearance / scale, - "trackWidth": design_settings.GetCurrentTrackWidth() / scale, - "viaDiameter": design_settings.GetCurrentViaSize() / scale, - "viaDrill": design_settings.GetCurrentViaDrill() / scale, - "microViaDiameter": design_settings.m_MicroViasMinSize / scale, - "microViaDrill": design_settings.m_MicroViasMinDrill / scale, - "minTrackWidth": design_settings.m_TrackMinWidth / scale, - "minViaDiameter": design_settings.m_ViasMinSize / scale, - "minThroughDrill": design_settings.m_MinThroughDrill / scale, - "minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale, - "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale, - "holeClearance": design_settings.m_HoleClearance / scale, - "holeToHoleMin": design_settings.m_HoleToHoleMin / scale, - "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale, - } - - return { - "success": True, - "message": "Updated design rules", - "rules": response_rules, - } - - except Exception as e: - logger.error(f"Error setting design rules: {str(e)}") - return { - "success": False, - "message": "Failed to set design rules", - "errorDetails": str(e), - } - - def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get current design rules - KiCAD 9.0 compatible""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - design_settings = self.board.GetDesignSettings() - scale = 1000000 # nm to mm - - # Build rules dict with KiCAD 9.0 compatible properties - rules = { - # Core clearance and track settings - "clearance": design_settings.m_MinClearance / scale, - "trackWidth": design_settings.GetCurrentTrackWidth() / scale, - "minTrackWidth": design_settings.m_TrackMinWidth / scale, - # Via settings (current values from methods) - "viaDiameter": design_settings.GetCurrentViaSize() / scale, - "viaDrill": design_settings.GetCurrentViaDrill() / scale, - # Via minimum values - "minViaDiameter": design_settings.m_ViasMinSize / scale, - "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale, - # Micro via settings - "microViaDiameter": design_settings.m_MicroViasMinSize / scale, - "microViaDrill": design_settings.m_MicroViasMinDrill / scale, - "minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale, - "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale, - # KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter) - "minThroughDrill": design_settings.m_MinThroughDrill / scale, - "holeClearance": design_settings.m_HoleClearance / scale, - "holeToHoleMin": design_settings.m_HoleToHoleMin / scale, - # Other constraints - "copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale, - "silkClearance": design_settings.m_SilkClearance / scale, - } - - return {"success": True, "rules": rules} - - except Exception as e: - logger.error(f"Error getting design rules: {str(e)}") - return { - "success": False, - "message": "Failed to get design rules", - "errorDetails": str(e), - } - - def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Run Design Rule Check using kicad-cli""" - import json - import platform - import shutil - import subprocess - import tempfile - - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - report_path = params.get("reportPath") - - # Get the board file path - board_file = self.board.GetFileName() - if not board_file or not os.path.exists(board_file): - return { - "success": False, - "message": "Board file not found", - "errorDetails": "Cannot run DRC without a saved board file", - } - - # Find kicad-cli executable - kicad_cli = self._find_kicad_cli() - if not kicad_cli: - return { - "success": False, - "message": "kicad-cli not found", - "errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH.", - } - - # Create temporary JSON output file - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: - json_output = tmp.name - - try: - # Build command - cmd = [ - kicad_cli, - "pcb", - "drc", - "--format", - "json", - "--output", - json_output, - "--units", - "mm", - board_file, - ] - - logger.info(f"Running DRC command: {' '.join(cmd)}") - - # Run DRC - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=600, # 10 minute timeout for large boards (21MB PCB needs time) - ) - - if result.returncode != 0: - logger.error(f"DRC command failed: {result.stderr}") - return { - "success": False, - "message": "DRC command failed", - "errorDetails": result.stderr, - } - - # Read JSON output - with open(json_output, "r", encoding="utf-8") as f: - drc_data = json.load(f) - - # Parse violations from kicad-cli output - violations = [] - violation_counts: dict[str, int] = {} - severity_counts = {"error": 0, "warning": 0, "info": 0} - - for violation in drc_data.get("violations", []): - 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": loc_x, - "y": loc_y, - "unit": "mm", - }, - } - ) - - # Count violations by type - violation_counts[vtype] = violation_counts.get(vtype, 0) + 1 - - # Count by severity - if vseverity in severity_counts: - severity_counts[vseverity] += 1 - - # Determine where to save the violations file - board_dir = os.path.dirname(board_file) - board_name = os.path.splitext(os.path.basename(board_file))[0] - violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json") - - # Always save violations to JSON file (for large result sets) - with open(violations_file, "w", encoding="utf-8") as f: - json.dump( - { - "board": board_file, - "timestamp": drc_data.get("date", "unknown"), - "total_violations": len(violations), - "violation_counts": violation_counts, - "severity_counts": severity_counts, - "violations": violations, - }, - f, - indent=2, - ) - - # Save text report if requested - if report_path: - report_path = os.path.abspath(os.path.expanduser(report_path)) - cmd_report = [ - kicad_cli, - "pcb", - "drc", - "--format", - "report", - "--output", - report_path, - "--units", - "mm", - board_file, - ] - subprocess.run(cmd_report, capture_output=True, timeout=600) - - # Return summary only (not full violations list) - return { - "success": True, - "message": f"Found {len(violations)} DRC violations", - "summary": { - "total": len(violations), - "by_severity": severity_counts, - "by_type": violation_counts, - }, - "violationsFile": violations_file, - "reportPath": report_path if report_path else None, - } - - finally: - # Clean up temp JSON file - if os.path.exists(json_output): - os.unlink(json_output) - - except subprocess.TimeoutExpired: - logger.error("DRC command timed out") - return { - "success": False, - "message": "DRC command timed out", - "errorDetails": "Command took longer than 600 seconds (10 minutes)", - } - except Exception as e: - logger.error(f"Error running DRC: {str(e)}") - return { - "success": False, - "message": "Failed to run DRC", - "errorDetails": str(e), - } - - def _find_kicad_cli(self) -> Optional[str]: - """Find kicad-cli executable""" - import platform - import shutil - - # Try system PATH first - cli_name = "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli" - cli_path = shutil.which(cli_name) - if cli_path: - return cli_path - - # Try common installation paths (version-specific) - if platform.system() == "Windows": - common_paths = [ - r"C:\Program Files\KiCad\10.0\bin\kicad-cli.exe", - r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe", - r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe", - r"C:\Program Files (x86)\KiCad\10.0\bin\kicad-cli.exe", - r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe", - r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe", - r"C:\Program Files\KiCad\bin\kicad-cli.exe", - ] - for path in common_paths: - if os.path.exists(path): - return path - elif platform.system() == "Darwin": # macOS - common_paths = [ - "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli", - "/usr/local/bin/kicad-cli", - ] - for path in common_paths: - if os.path.exists(path): - return path - else: # Linux - common_paths = [ - "/usr/bin/kicad-cli", - "/usr/local/bin/kicad-cli", - ] - for path in common_paths: - if os.path.exists(path): - return path - - return None - - def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]: - """ - Get list of DRC violations - - Note: This command internally uses run_drc() which calls kicad-cli. - The old BOARD.GetDRCMarkers() API was removed in KiCAD 9.0. - This implementation provides backward compatibility by parsing kicad-cli output. - """ - import json - - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - severity = params.get("severity", "all") - - # Run DRC using kicad-cli (this saves violations to JSON file) - drc_result = self.run_drc({}) - - if not drc_result.get("success"): - return drc_result # Return the error from run_drc - - # Read violations from the saved JSON file - violations_file = drc_result.get("violationsFile") - if not violations_file or not os.path.exists(violations_file): - return { - "success": False, - "message": "Violations file not found", - "errorDetails": "run_drc did not create violations file", - } - - # Load violations from file - with open(violations_file, "r", encoding="utf-8") as f: - data = json.load(f) - - all_violations = data.get("violations", []) - - # Filter by severity if specified - if severity != "all": - filtered_violations = [v for v in all_violations if v.get("severity") == severity] - else: - filtered_violations = all_violations - - return { - "success": True, - "violations": filtered_violations, - "violationsFile": violations_file, # Include file path for reference - } - - except Exception as e: - logger.error(f"Error getting DRC violations: {str(e)}") - return { - "success": False, - "message": "Failed to get DRC violations", - "errorDetails": str(e), - } +""" +Design rules command implementations for KiCAD interface +""" + +import logging +import os +from typing import Any, Dict, List, Optional, Tuple + +import pcbnew + +logger = logging.getLogger("kicad_interface") + + +class DesignRuleCommands: + """Handles design rule checking and configuration""" + + def __init__(self, board: Optional[pcbnew.BOARD] = None): + """Initialize with optional board instance""" + self.board = board + + def set_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Set design rules for the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + design_settings = self.board.GetDesignSettings() + + # Convert mm to nanometers for KiCAD internal units + scale = 1000000 # mm to nm + + # Set clearance + if "clearance" in params: + design_settings.m_MinClearance = int(params["clearance"] * scale) + + # KiCAD 9.0: Use SetCustom* methods instead of SetCurrent* (which were removed) + # Track if we set any custom track/via values + custom_values_set = False + + if "trackWidth" in params: + design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale)) + custom_values_set = True + + # Via settings + if "viaDiameter" in params: + design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale)) + custom_values_set = True + if "viaDrill" in params: + design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale)) + custom_values_set = True + + # KiCAD 9.0: Activate custom track/via values so they become the current values + if custom_values_set: + design_settings.UseCustomTrackViaSize(True) + + # Set micro via settings (use properties - methods removed in KiCAD 9.0) + if "microViaDiameter" in params: + design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale) + if "microViaDrill" in params: + design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale) + + # Set minimum values + if "minTrackWidth" in params: + design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale) + if "minViaDiameter" in params: + design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale) + + # KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead + if "minViaDrill" in params: + design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale) + + if "minMicroViaDiameter" in params: + design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale) + if "minMicroViaDrill" in params: + design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale) + + # KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill + if "minHoleDiameter" in params: + design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale) + + # KiCAD 9.0: Added hole clearance settings + if "holeClearance" in params: + design_settings.m_HoleClearance = int(params["holeClearance"] * scale) + if "holeToHoleMin" in params: + design_settings.m_HoleToHoleMin = int(params["holeToHoleMin"] * scale) + + # Build response with KiCAD 9.0 compatible properties + # After UseCustomTrackViaSize(True), GetCurrent* returns the custom values + response_rules = { + "clearance": design_settings.m_MinClearance / scale, + "trackWidth": design_settings.GetCurrentTrackWidth() / scale, + "viaDiameter": design_settings.GetCurrentViaSize() / scale, + "viaDrill": design_settings.GetCurrentViaDrill() / scale, + "microViaDiameter": design_settings.m_MicroViasMinSize / scale, + "microViaDrill": design_settings.m_MicroViasMinDrill / scale, + "minTrackWidth": design_settings.m_TrackMinWidth / scale, + "minViaDiameter": design_settings.m_ViasMinSize / scale, + "minThroughDrill": design_settings.m_MinThroughDrill / scale, + "minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale, + "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale, + "holeClearance": design_settings.m_HoleClearance / scale, + "holeToHoleMin": design_settings.m_HoleToHoleMin / scale, + "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale, + } + + return { + "success": True, + "message": "Updated design rules", + "rules": response_rules, + } + + except Exception as e: + logger.error(f"Error setting design rules: {str(e)}") + return { + "success": False, + "message": "Failed to set design rules", + "errorDetails": str(e), + } + + def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get current design rules - KiCAD 9.0 compatible""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + design_settings = self.board.GetDesignSettings() + scale = 1000000 # nm to mm + + # Build rules dict with KiCAD 9.0 compatible properties + rules = { + # Core clearance and track settings + "clearance": design_settings.m_MinClearance / scale, + "trackWidth": design_settings.GetCurrentTrackWidth() / scale, + "minTrackWidth": design_settings.m_TrackMinWidth / scale, + # Via settings (current values from methods) + "viaDiameter": design_settings.GetCurrentViaSize() / scale, + "viaDrill": design_settings.GetCurrentViaDrill() / scale, + # Via minimum values + "minViaDiameter": design_settings.m_ViasMinSize / scale, + "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale, + # Micro via settings + "microViaDiameter": design_settings.m_MicroViasMinSize / scale, + "microViaDrill": design_settings.m_MicroViasMinDrill / scale, + "minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale, + "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale, + # KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter) + "minThroughDrill": design_settings.m_MinThroughDrill / scale, + "holeClearance": design_settings.m_HoleClearance / scale, + "holeToHoleMin": design_settings.m_HoleToHoleMin / scale, + # Other constraints + "copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale, + "silkClearance": design_settings.m_SilkClearance / scale, + } + + return {"success": True, "rules": rules} + + except Exception as e: + logger.error(f"Error getting design rules: {str(e)}") + return { + "success": False, + "message": "Failed to get design rules", + "errorDetails": str(e), + } + + def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Run Design Rule Check using kicad-cli""" + import json + import platform + import shutil + import subprocess + import tempfile + + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + report_path = params.get("reportPath") + + # Get the board file path + board_file = self.board.GetFileName() + if not board_file or not os.path.exists(board_file): + return { + "success": False, + "message": "Board file not found", + "errorDetails": "Cannot run DRC without a saved board file", + } + + # Find kicad-cli executable + kicad_cli = self._find_kicad_cli() + if not kicad_cli: + return { + "success": False, + "message": "kicad-cli not found", + "errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH.", + } + + # Create temporary JSON output file + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp: + json_output = tmp.name + + try: + # Build command + cmd = [ + kicad_cli, + "pcb", + "drc", + "--format", + "json", + "--output", + json_output, + "--units", + "mm", + board_file, + ] + + logger.info(f"Running DRC command: {' '.join(cmd)}") + + # Run DRC + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=600, # 10 minute timeout for large boards (21MB PCB needs time) + ) + + if result.returncode != 0: + logger.error(f"DRC command failed: {result.stderr}") + return { + "success": False, + "message": "DRC command failed", + "errorDetails": result.stderr, + } + + # Read JSON output + with open(json_output, "r", encoding="utf-8") as f: + drc_data = json.load(f) + + # Parse violations from kicad-cli output + violations = [] + violation_counts: dict[str, int] = {} + severity_counts = {"error": 0, "warning": 0, "info": 0} + + for violation in drc_data.get("violations", []): + 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": loc_x, + "y": loc_y, + "unit": "mm", + }, + } + ) + + # Count violations by type + violation_counts[vtype] = violation_counts.get(vtype, 0) + 1 + + # Count by severity + if vseverity in severity_counts: + severity_counts[vseverity] += 1 + + # Determine where to save the violations file + board_dir = os.path.dirname(board_file) + board_name = os.path.splitext(os.path.basename(board_file))[0] + violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json") + + # Always save violations to JSON file (for large result sets) + with open(violations_file, "w", encoding="utf-8") as f: + json.dump( + { + "board": board_file, + "timestamp": drc_data.get("date", "unknown"), + "total_violations": len(violations), + "violation_counts": violation_counts, + "severity_counts": severity_counts, + "violations": violations, + }, + f, + indent=2, + ) + + # Save text report if requested + if report_path: + report_path = os.path.abspath(os.path.expanduser(report_path)) + cmd_report = [ + kicad_cli, + "pcb", + "drc", + "--format", + "report", + "--output", + report_path, + "--units", + "mm", + board_file, + ] + subprocess.run(cmd_report, capture_output=True, timeout=600) + + # Return summary only (not full violations list) + return { + "success": True, + "message": f"Found {len(violations)} DRC violations", + "summary": { + "total": len(violations), + "by_severity": severity_counts, + "by_type": violation_counts, + }, + "violationsFile": violations_file, + "reportPath": report_path if report_path else None, + } + + finally: + # Clean up temp JSON file + if os.path.exists(json_output): + os.unlink(json_output) + + except subprocess.TimeoutExpired: + logger.error("DRC command timed out") + return { + "success": False, + "message": "DRC command timed out", + "errorDetails": "Command took longer than 600 seconds (10 minutes)", + } + except Exception as e: + logger.error(f"Error running DRC: {str(e)}") + return { + "success": False, + "message": "Failed to run DRC", + "errorDetails": str(e), + } + + def _find_kicad_cli(self) -> Optional[str]: + """Find kicad-cli executable""" + import platform + import shutil + + # Try system PATH first + cli_name = "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli" + cli_path = shutil.which(cli_name) + if cli_path: + return cli_path + + # Try common installation paths (version-specific) + if platform.system() == "Windows": + common_paths = [ + r"C:\Program Files\KiCad\10.0\bin\kicad-cli.exe", + r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe", + r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe", + r"C:\Program Files (x86)\KiCad\10.0\bin\kicad-cli.exe", + r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe", + r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe", + r"C:\Program Files\KiCad\bin\kicad-cli.exe", + ] + for path in common_paths: + if os.path.exists(path): + return path + elif platform.system() == "Darwin": # macOS + common_paths = [ + "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli", + "/usr/local/bin/kicad-cli", + ] + for path in common_paths: + if os.path.exists(path): + return path + else: # Linux + common_paths = [ + "/usr/bin/kicad-cli", + "/usr/local/bin/kicad-cli", + ] + for path in common_paths: + if os.path.exists(path): + return path + + return None + + def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]: + """ + Get list of DRC violations + + Note: This command internally uses run_drc() which calls kicad-cli. + The old BOARD.GetDRCMarkers() API was removed in KiCAD 9.0. + This implementation provides backward compatibility by parsing kicad-cli output. + """ + import json + + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + severity = params.get("severity", "all") + + # Run DRC using kicad-cli (this saves violations to JSON file) + drc_result = self.run_drc({}) + + if not drc_result.get("success"): + return drc_result # Return the error from run_drc + + # Read violations from the saved JSON file + violations_file = drc_result.get("violationsFile") + if not violations_file or not os.path.exists(violations_file): + return { + "success": False, + "message": "Violations file not found", + "errorDetails": "run_drc did not create violations file", + } + + # Load violations from file + with open(violations_file, "r", encoding="utf-8") as f: + data = json.load(f) + + all_violations = data.get("violations", []) + + # Filter by severity if specified + if severity != "all": + filtered_violations = [v for v in all_violations if v.get("severity") == severity] + else: + filtered_violations = all_violations + + return { + "success": True, + "violations": filtered_violations, + "violationsFile": violations_file, # Include file path for reference + } + + except Exception as e: + logger.error(f"Error getting DRC violations: {str(e)}") + return { + "success": False, + "message": "Failed to get DRC violations", + "errorDetails": str(e), + } diff --git a/python/commands/export.py b/python/commands/export.py index 4311a48..ad152e3 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -1,697 +1,697 @@ -""" -Export command implementations for KiCAD interface -""" - -import base64 -import logging -import os -import shutil -from datetime import datetime -from typing import Any, Dict, List, Optional, Tuple - -import pcbnew - -logger = logging.getLogger("kicad_interface") - - -class ExportCommands: - """Handles export-related KiCAD operations""" - - def __init__(self, board: Optional[pcbnew.BOARD] = None): - """Initialize with optional board instance""" - self.board = board - - def export_gerber(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Export Gerber files""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - output_dir = params.get("outputDir") - layers = params.get("layers", []) - use_protel_extensions = params.get("useProtelExtensions", False) - generate_drill_files = params.get("generateDrillFiles", True) - generate_map_file = params.get("generateMapFile", False) - use_aux_origin = params.get("useAuxOrigin", False) - - if not output_dir: - return { - "success": False, - "message": "Missing output directory", - "errorDetails": "outputDir parameter is required", - } - - # Create output directory if it doesn't exist - output_dir = os.path.abspath(os.path.expanduser(output_dir)) - os.makedirs(output_dir, exist_ok=True) - - # Create plot controller - plotter = pcbnew.PLOT_CONTROLLER(self.board) - - # Set up plot options - plot_opts = plotter.GetPlotOptions() - plot_opts.SetOutputDirectory(output_dir) - plot_opts.SetFormat(pcbnew.PLOT_FORMAT_GERBER) - plot_opts.SetUseGerberProtelExtensions(use_protel_extensions) - plot_opts.SetUseAuxOrigin(use_aux_origin) - plot_opts.SetCreateGerberJobFile(generate_map_file) - plot_opts.SetSubtractMaskFromSilk(True) - - # Plot specified layers or all copper layers - plotted_layers = [] - if layers: - for layer_name in layers: - layer_id = self.board.GetLayerID(layer_name) - if layer_id >= 0: - plotter.SetLayer(layer_id) - plotter.PlotLayer() - plotted_layers.append(layer_name) - else: - for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): - if self.board.IsLayerEnabled(layer_id): - layer_name = self.board.GetLayerName(layer_id) - plotter.SetLayer(layer_id) - plotter.PlotLayer() - plotted_layers.append(layer_name) - - # Generate drill files if requested - drill_files = [] - if generate_drill_files: - # KiCAD 9.0: Use kicad-cli for more reliable drill file generation - # The Python API's EXCELLON_WRITER.SetOptions() signature changed - board_file = self.board.GetFileName() - kicad_cli = self._find_kicad_cli() - - 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, - ] - - try: - 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}") - except Exception as drill_error: - logger.warning(f"Could not generate drill files: {str(drill_error)}") - else: - logger.warning("kicad-cli not available for drill file generation") - - # DEV MODE: copy MCP server log into project folder for later analysis - if os.environ.get("KICAD_MCP_DEV") == "1": - try: - self._dev_copy_mcp_log(output_dir) - except Exception as dev_err: - logger.warning(f"[DEV] Could not copy MCP log: {dev_err}") - - return { - "success": True, - "message": "Exported Gerber files", - "files": { - "gerber": plotted_layers, - "drill": drill_files, - "map": ["job.gbrjob"] if generate_map_file else [], - }, - "outputDir": output_dir, - } - - except Exception as e: - logger.error(f"Error exporting Gerber files: {str(e)}") - return { - "success": False, - "message": "Failed to export Gerber files", - "errorDetails": str(e), - } - - def export_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Export PDF files""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - output_path = params.get("outputPath") - layers = params.get("layers", []) - black_and_white = params.get("blackAndWhite", False) - frame_reference = params.get("frameReference", True) - page_size = params.get("pageSize", "A4") - - if not output_path: - return { - "success": False, - "message": "Missing output path", - "errorDetails": "outputPath parameter is required", - } - - # Create output directory if it doesn't exist - output_path = os.path.abspath(os.path.expanduser(output_path)) - os.makedirs(os.path.dirname(output_path), exist_ok=True) - - # 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)) - plot_opts.SetFormat(pcbnew.PLOT_FORMAT_PDF) - plot_opts.SetPlotFrameRef(frame_reference) - plot_opts.SetPlotValue(True) - plot_opts.SetPlotReference(True) - plot_opts.SetBlackAndWhite(black_and_white) - - # KiCAD 9.0 page size handling: - # - SetPageSettings() was removed in KiCAD 9.0 - # - SetA4Output(bool) forces A4 page size when True - # - For other sizes, KiCAD auto-scales to fit the board - # - SetAutoScale(True) enables automatic scaling to fit page - if page_size == "A4": - plot_opts.SetA4Output(True) - else: - # For non-A4 sizes, disable A4 forcing and use auto-scale - plot_opts.SetA4Output(False) - 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." - ) - - # 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, "") - - # Plot specified layers or all enabled layers - plotted_layers = [] - if layers: - for layer_name in layers: - layer_id = self.board.GetLayerID(layer_name) - if layer_id >= 0: - plotter.SetLayer(layer_id) - plotter.PlotLayer() - plotted_layers.append(layer_name) - else: - for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): - if self.board.IsLayerEnabled(layer_id): - layer_name = self.board.GetLayerName(layer_id) - plotter.SetLayer(layer_id) - plotter.PlotLayer() - plotted_layers.append(layer_name) - - # Close the plot file to finalize the PDF - plotter.ClosePlot() - - # KiCAD automatically prepends the board name to the output file - # 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) - - return { - "success": True, - "message": "Exported PDF file", - "file": { - "path": actual_output_path, - "requestedPath": output_path, - "layers": plotted_layers, - "pageSize": page_size if page_size == "A4" else "auto-scaled", - }, - } - - except Exception as e: - logger.error(f"Error exporting PDF file: {str(e)}") - return { - "success": False, - "message": "Failed to export PDF file", - "errorDetails": str(e), - } - - def export_svg(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Export SVG files""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - output_path = params.get("outputPath") - layers = params.get("layers", []) - black_and_white = params.get("blackAndWhite", False) - include_components = params.get("includeComponents", True) - - if not output_path: - return { - "success": False, - "message": "Missing output path", - "errorDetails": "outputPath parameter is required", - } - - # Create output directory if it doesn't exist - output_path = os.path.abspath(os.path.expanduser(output_path)) - os.makedirs(os.path.dirname(output_path), exist_ok=True) - - # 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)) - plot_opts.SetFormat(pcbnew.PLOT_FORMAT_SVG) - plot_opts.SetPlotValue(include_components) - plot_opts.SetPlotReference(include_components) - plot_opts.SetBlackAndWhite(black_and_white) - - # Plot specified layers or all enabled layers - plotted_layers = [] - if layers: - for layer_name in layers: - layer_id = self.board.GetLayerID(layer_name) - if layer_id >= 0: - plotter.SetLayer(layer_id) - plotter.PlotLayer() - plotted_layers.append(layer_name) - else: - for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): - if self.board.IsLayerEnabled(layer_id): - layer_name = self.board.GetLayerName(layer_id) - plotter.SetLayer(layer_id) - plotter.PlotLayer() - plotted_layers.append(layer_name) - - return { - "success": True, - "message": "Exported SVG file", - "file": {"path": output_path, "layers": plotted_layers}, - } - - except Exception as e: - logger.error(f"Error exporting SVG file: {str(e)}") - return { - "success": False, - "message": "Failed to export SVG file", - "errorDetails": str(e), - } - - def export_3d(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Export 3D model files using kicad-cli (KiCAD 9.0 compatible)""" - import platform - import shutil - import subprocess - - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - output_path = params.get("outputPath") - format = params.get("format", "STEP") - include_components = params.get("includeComponents", True) - include_copper = params.get("includeCopper", True) - include_solder_mask = params.get("includeSolderMask", True) - include_silkscreen = params.get("includeSilkscreen", True) - - if not output_path: - return { - "success": False, - "message": "Missing output path", - "errorDetails": "outputPath parameter is required", - } - - # Get board file path - board_file = self.board.GetFileName() - if not board_file or not os.path.exists(board_file): - return { - "success": False, - "message": "Board file not found", - "errorDetails": "Board must be saved before exporting 3D models", - } - - # Create output directory if it doesn't exist - output_path = os.path.abspath(os.path.expanduser(output_path)) - os.makedirs(os.path.dirname(output_path), exist_ok=True) - - # Find kicad-cli executable - kicad_cli = self._find_kicad_cli() - if not kicad_cli: - return { - "success": False, - "message": "kicad-cli not found", - "errorDetails": "KiCAD CLI tool not found. Install KiCAD 8.0+ or set PATH.", - } - - # Build command based on format - format_upper = format.upper() - - if format_upper == "STEP": - cmd = [ - kicad_cli, - "pcb", - "export", - "step", - "--output", - output_path, - "--force", # Overwrite existing file - ] - - # Add options based on parameters - if not include_components: - cmd.append("--no-components") - if include_copper: - cmd.extend(["--include-tracks", "--include-pads", "--include-zones"]) - if include_silkscreen: - cmd.append("--include-silkscreen") - if include_solder_mask: - 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", - ] - - if not include_components: - # Note: VRML export doesn't have a direct --no-components flag - # The models will be included by default, but can be controlled via 3D settings - pass - - cmd.append(board_file) - - else: - return { - "success": False, - "message": "Unsupported format", - "errorDetails": f"Format {format} is not supported. Use 'STEP' or 'VRML'.", - } - - # Execute kicad-cli command - logger.info(f"Running 3D export command: {' '.join(cmd)}") - - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=300, # 5 minute timeout for 3D export - ) - - if result.returncode != 0: - logger.error(f"3D export command failed: {result.stderr}") - return { - "success": False, - "message": "3D export command failed", - "errorDetails": result.stderr, - } - - return { - "success": True, - "message": f"Exported {format_upper} file", - "file": {"path": output_path, "format": format_upper}, - } - - except subprocess.TimeoutExpired: - logger.error("3D export command timed out") - return { - "success": False, - "message": "3D export timed out", - "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), - } - - def export_bom(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Export Bill of Materials""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - output_path = params.get("outputPath") - format = params.get("format", "CSV") - group_by_value = params.get("groupByValue", True) - include_attributes = params.get("includeAttributes", []) - - if not output_path: - return { - "success": False, - "message": "Missing output path", - "errorDetails": "outputPath parameter is required", - } - - # Create output directory if it doesn't exist - output_path = os.path.abspath(os.path.expanduser(output_path)) - os.makedirs(os.path.dirname(output_path), exist_ok=True) - - # Get all components - components = [] - for module in self.board.GetFootprints(): - component = { - "reference": module.GetReference(), - "value": module.GetValue(), - "footprint": module.GetFPID().GetUniStringLibId(), - "layer": self.board.GetLayerName(module.GetLayer()), - } - - # Add requested attributes - for attr in include_attributes: - if hasattr(module, f"Get{attr}"): - component[attr] = getattr(module, f"Get{attr}")() - - components.append(component) - - # Group by value if requested - if group_by_value: - grouped = {} - for comp in components: - key = f"{comp['value']}_{comp['footprint']}" - if key not in grouped: - grouped[key] = { - "value": comp["value"], - "footprint": comp["footprint"], - "quantity": 1, - "references": [comp["reference"]], - } - else: - grouped[key]["quantity"] += 1 - grouped[key]["references"].append(comp["reference"]) - components = list(grouped.values()) - - # Export based on format - if format == "CSV": - self._export_bom_csv(output_path, components) - elif format == "XML": - self._export_bom_xml(output_path, components) - elif format == "HTML": - self._export_bom_html(output_path, components) - elif format == "JSON": - self._export_bom_json(output_path, components) - else: - return { - "success": False, - "message": "Unsupported format", - "errorDetails": f"Format {format} is not supported", - } - - return { - "success": True, - "message": f"Exported BOM to {format}", - "file": { - "path": output_path, - "format": format, - "componentCount": len(components), - }, - } - - except Exception as e: - logger.error(f"Error exporting BOM: {str(e)}") - return { - "success": False, - "message": "Failed to export BOM", - "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: - writer = csv.DictWriter(f, fieldnames=components[0].keys()) - writer.writeheader() - writer.writerows(components) - - 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") - for key, value in comp.items(): - elem = ET.SubElement(comp_elem, key) - elem.text = str(value) - tree = ET.ElementTree(root) - 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""" - html = ["Bill of Materials"] - html.append("") - # Headers - for key in components[0].keys(): - html.append(f"") - html.append("") - # Data - for comp in components: - html.append("") - for value in comp.values(): - html.append(f"") - html.append("") - html.append("
{key}
{value}
") - 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: - json.dump({"components": components}, f, indent=2) - - def _find_kicad_cli(self) -> Optional[str]: - """Find kicad-cli executable in system PATH or common locations - - Returns: - Path to kicad-cli executable, or None if not found - """ - import platform - import shutil - - # Try system PATH first - cli_path = shutil.which("kicad-cli") - if cli_path: - return cli_path - - # Try platform-specific default locations - system = platform.system() - - if system == "Windows": - possible_paths = [ - r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe", - r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe", - r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe", - r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe", - ] - elif system == "Darwin": # macOS - possible_paths = [ - "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli", - "/usr/local/bin/kicad-cli", - ] - else: # Linux - possible_paths = [ - "/usr/bin/kicad-cli", - "/usr/local/bin/kicad-cli", - ] - - for path in possible_paths: - if os.path.exists(path): - return path - - return None - - def _dev_copy_mcp_log(self, output_dir: str) -> None: - """DEV MODE: Copy the MCP server log for the current session into the project folder. - - Activated by env var KICAD_MCP_DEV=1. - The log is placed alongside the Gerber output as: - /mcp_log_.txt - - Only lines from the current server session (today's date) are included - to keep the file focused on the relevant run. - """ - import platform - - # Resolve Claude log path per platform - system = platform.system() - if system == "Windows": - log_dir = os.path.join(os.environ.get("APPDATA", ""), "Claude", "logs") - elif system == "Darwin": - log_dir = os.path.expanduser("~/Library/Logs/Claude") - else: - log_dir = os.path.expanduser("~/.config/Claude/logs") - - log_src = os.path.join(log_dir, "mcp-server-kicad.log") - if not os.path.exists(log_src): - logger.warning(f"[DEV] MCP log not found at: {log_src}") - return - - # Project dir = parent of outputDir (the Gerber subfolder) - project_dir = os.path.dirname(output_dir) - - # Extract only lines from the current session start (find last "Initializing server") - with open(log_src, "r", encoding="utf-8", errors="replace") as f: - all_lines = f.readlines() - - # Find last occurrence of server start so we get only the current run - session_start = 0 - for i, line in enumerate(all_lines): - if "Initializing server" in line: - session_start = i - - session_lines = all_lines[session_start:] - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - from pathlib import Path - - logs_dir = Path(project_dir) / "logs" - logs_dir.mkdir(exist_ok=True) - dest = str(logs_dir / f"mcp_log_{timestamp}.txt") - with open(dest, "w", encoding="utf-8") as f: - f.writelines(session_lines) - - logger.info(f"[DEV] MCP session log saved to: {dest} ({len(session_lines)} lines)") +""" +Export command implementations for KiCAD interface +""" + +import base64 +import logging +import os +import shutil +from datetime import datetime +from typing import Any, Dict, List, Optional, Tuple + +import pcbnew + +logger = logging.getLogger("kicad_interface") + + +class ExportCommands: + """Handles export-related KiCAD operations""" + + def __init__(self, board: Optional[pcbnew.BOARD] = None): + """Initialize with optional board instance""" + self.board = board + + def export_gerber(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export Gerber files""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + output_dir = params.get("outputDir") + layers = params.get("layers", []) + use_protel_extensions = params.get("useProtelExtensions", False) + generate_drill_files = params.get("generateDrillFiles", True) + generate_map_file = params.get("generateMapFile", False) + use_aux_origin = params.get("useAuxOrigin", False) + + if not output_dir: + return { + "success": False, + "message": "Missing output directory", + "errorDetails": "outputDir parameter is required", + } + + # Create output directory if it doesn't exist + output_dir = os.path.abspath(os.path.expanduser(output_dir)) + os.makedirs(output_dir, exist_ok=True) + + # Create plot controller + plotter = pcbnew.PLOT_CONTROLLER(self.board) + + # Set up plot options + plot_opts = plotter.GetPlotOptions() + plot_opts.SetOutputDirectory(output_dir) + plot_opts.SetFormat(pcbnew.PLOT_FORMAT_GERBER) + plot_opts.SetUseGerberProtelExtensions(use_protel_extensions) + plot_opts.SetUseAuxOrigin(use_aux_origin) + plot_opts.SetCreateGerberJobFile(generate_map_file) + plot_opts.SetSubtractMaskFromSilk(True) + + # Plot specified layers or all copper layers + plotted_layers = [] + if layers: + for layer_name in layers: + layer_id = self.board.GetLayerID(layer_name) + if layer_id >= 0: + plotter.SetLayer(layer_id) + plotter.PlotLayer() + plotted_layers.append(layer_name) + else: + for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): + if self.board.IsLayerEnabled(layer_id): + layer_name = self.board.GetLayerName(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() + plotted_layers.append(layer_name) + + # Generate drill files if requested + drill_files = [] + if generate_drill_files: + # KiCAD 9.0: Use kicad-cli for more reliable drill file generation + # The Python API's EXCELLON_WRITER.SetOptions() signature changed + board_file = self.board.GetFileName() + kicad_cli = self._find_kicad_cli() + + 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, + ] + + try: + 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}") + except Exception as drill_error: + logger.warning(f"Could not generate drill files: {str(drill_error)}") + else: + logger.warning("kicad-cli not available for drill file generation") + + # DEV MODE: copy MCP server log into project folder for later analysis + if os.environ.get("KICAD_MCP_DEV") == "1": + try: + self._dev_copy_mcp_log(output_dir) + except Exception as dev_err: + logger.warning(f"[DEV] Could not copy MCP log: {dev_err}") + + return { + "success": True, + "message": "Exported Gerber files", + "files": { + "gerber": plotted_layers, + "drill": drill_files, + "map": ["job.gbrjob"] if generate_map_file else [], + }, + "outputDir": output_dir, + } + + except Exception as e: + logger.error(f"Error exporting Gerber files: {str(e)}") + return { + "success": False, + "message": "Failed to export Gerber files", + "errorDetails": str(e), + } + + def export_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export PDF files""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + output_path = params.get("outputPath") + layers = params.get("layers", []) + black_and_white = params.get("blackAndWhite", False) + frame_reference = params.get("frameReference", True) + page_size = params.get("pageSize", "A4") + + if not output_path: + return { + "success": False, + "message": "Missing output path", + "errorDetails": "outputPath parameter is required", + } + + # Create output directory if it doesn't exist + output_path = os.path.abspath(os.path.expanduser(output_path)) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # 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)) + plot_opts.SetFormat(pcbnew.PLOT_FORMAT_PDF) + plot_opts.SetPlotFrameRef(frame_reference) + plot_opts.SetPlotValue(True) + plot_opts.SetPlotReference(True) + plot_opts.SetBlackAndWhite(black_and_white) + + # KiCAD 9.0 page size handling: + # - SetPageSettings() was removed in KiCAD 9.0 + # - SetA4Output(bool) forces A4 page size when True + # - For other sizes, KiCAD auto-scales to fit the board + # - SetAutoScale(True) enables automatic scaling to fit page + if page_size == "A4": + plot_opts.SetA4Output(True) + else: + # For non-A4 sizes, disable A4 forcing and use auto-scale + plot_opts.SetA4Output(False) + 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." + ) + + # 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, "") + + # Plot specified layers or all enabled layers + plotted_layers = [] + if layers: + for layer_name in layers: + layer_id = self.board.GetLayerID(layer_name) + if layer_id >= 0: + plotter.SetLayer(layer_id) + plotter.PlotLayer() + plotted_layers.append(layer_name) + else: + for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): + if self.board.IsLayerEnabled(layer_id): + layer_name = self.board.GetLayerName(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() + plotted_layers.append(layer_name) + + # Close the plot file to finalize the PDF + plotter.ClosePlot() + + # KiCAD automatically prepends the board name to the output file + # 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) + + return { + "success": True, + "message": "Exported PDF file", + "file": { + "path": actual_output_path, + "requestedPath": output_path, + "layers": plotted_layers, + "pageSize": page_size if page_size == "A4" else "auto-scaled", + }, + } + + except Exception as e: + logger.error(f"Error exporting PDF file: {str(e)}") + return { + "success": False, + "message": "Failed to export PDF file", + "errorDetails": str(e), + } + + def export_svg(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export SVG files""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + output_path = params.get("outputPath") + layers = params.get("layers", []) + black_and_white = params.get("blackAndWhite", False) + include_components = params.get("includeComponents", True) + + if not output_path: + return { + "success": False, + "message": "Missing output path", + "errorDetails": "outputPath parameter is required", + } + + # Create output directory if it doesn't exist + output_path = os.path.abspath(os.path.expanduser(output_path)) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # 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)) + plot_opts.SetFormat(pcbnew.PLOT_FORMAT_SVG) + plot_opts.SetPlotValue(include_components) + plot_opts.SetPlotReference(include_components) + plot_opts.SetBlackAndWhite(black_and_white) + + # Plot specified layers or all enabled layers + plotted_layers = [] + if layers: + for layer_name in layers: + layer_id = self.board.GetLayerID(layer_name) + if layer_id >= 0: + plotter.SetLayer(layer_id) + plotter.PlotLayer() + plotted_layers.append(layer_name) + else: + for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): + if self.board.IsLayerEnabled(layer_id): + layer_name = self.board.GetLayerName(layer_id) + plotter.SetLayer(layer_id) + plotter.PlotLayer() + plotted_layers.append(layer_name) + + return { + "success": True, + "message": "Exported SVG file", + "file": {"path": output_path, "layers": plotted_layers}, + } + + except Exception as e: + logger.error(f"Error exporting SVG file: {str(e)}") + return { + "success": False, + "message": "Failed to export SVG file", + "errorDetails": str(e), + } + + def export_3d(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export 3D model files using kicad-cli (KiCAD 9.0 compatible)""" + import platform + import shutil + import subprocess + + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + output_path = params.get("outputPath") + format = params.get("format", "STEP") + include_components = params.get("includeComponents", True) + include_copper = params.get("includeCopper", True) + include_solder_mask = params.get("includeSolderMask", True) + include_silkscreen = params.get("includeSilkscreen", True) + + if not output_path: + return { + "success": False, + "message": "Missing output path", + "errorDetails": "outputPath parameter is required", + } + + # Get board file path + board_file = self.board.GetFileName() + if not board_file or not os.path.exists(board_file): + return { + "success": False, + "message": "Board file not found", + "errorDetails": "Board must be saved before exporting 3D models", + } + + # Create output directory if it doesn't exist + output_path = os.path.abspath(os.path.expanduser(output_path)) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # Find kicad-cli executable + kicad_cli = self._find_kicad_cli() + if not kicad_cli: + return { + "success": False, + "message": "kicad-cli not found", + "errorDetails": "KiCAD CLI tool not found. Install KiCAD 8.0+ or set PATH.", + } + + # Build command based on format + format_upper = format.upper() + + if format_upper == "STEP": + cmd = [ + kicad_cli, + "pcb", + "export", + "step", + "--output", + output_path, + "--force", # Overwrite existing file + ] + + # Add options based on parameters + if not include_components: + cmd.append("--no-components") + if include_copper: + cmd.extend(["--include-tracks", "--include-pads", "--include-zones"]) + if include_silkscreen: + cmd.append("--include-silkscreen") + if include_solder_mask: + 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", + ] + + if not include_components: + # Note: VRML export doesn't have a direct --no-components flag + # The models will be included by default, but can be controlled via 3D settings + pass + + cmd.append(board_file) + + else: + return { + "success": False, + "message": "Unsupported format", + "errorDetails": f"Format {format} is not supported. Use 'STEP' or 'VRML'.", + } + + # Execute kicad-cli command + logger.info(f"Running 3D export command: {' '.join(cmd)}") + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=300, # 5 minute timeout for 3D export + ) + + if result.returncode != 0: + logger.error(f"3D export command failed: {result.stderr}") + return { + "success": False, + "message": "3D export command failed", + "errorDetails": result.stderr, + } + + return { + "success": True, + "message": f"Exported {format_upper} file", + "file": {"path": output_path, "format": format_upper}, + } + + except subprocess.TimeoutExpired: + logger.error("3D export command timed out") + return { + "success": False, + "message": "3D export timed out", + "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), + } + + def export_bom(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Export Bill of Materials""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + output_path = params.get("outputPath") + format = params.get("format", "CSV") + group_by_value = params.get("groupByValue", True) + include_attributes = params.get("includeAttributes", []) + + if not output_path: + return { + "success": False, + "message": "Missing output path", + "errorDetails": "outputPath parameter is required", + } + + # Create output directory if it doesn't exist + output_path = os.path.abspath(os.path.expanduser(output_path)) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # Get all components + components = [] + for module in self.board.GetFootprints(): + component = { + "reference": module.GetReference(), + "value": module.GetValue(), + "footprint": module.GetFPID().GetUniStringLibId(), + "layer": self.board.GetLayerName(module.GetLayer()), + } + + # Add requested attributes + for attr in include_attributes: + if hasattr(module, f"Get{attr}"): + component[attr] = getattr(module, f"Get{attr}")() + + components.append(component) + + # Group by value if requested + if group_by_value: + grouped = {} + for comp in components: + key = f"{comp['value']}_{comp['footprint']}" + if key not in grouped: + grouped[key] = { + "value": comp["value"], + "footprint": comp["footprint"], + "quantity": 1, + "references": [comp["reference"]], + } + else: + grouped[key]["quantity"] += 1 + grouped[key]["references"].append(comp["reference"]) + components = list(grouped.values()) + + # Export based on format + if format == "CSV": + self._export_bom_csv(output_path, components) + elif format == "XML": + self._export_bom_xml(output_path, components) + elif format == "HTML": + self._export_bom_html(output_path, components) + elif format == "JSON": + self._export_bom_json(output_path, components) + else: + return { + "success": False, + "message": "Unsupported format", + "errorDetails": f"Format {format} is not supported", + } + + return { + "success": True, + "message": f"Exported BOM to {format}", + "file": { + "path": output_path, + "format": format, + "componentCount": len(components), + }, + } + + except Exception as e: + logger.error(f"Error exporting BOM: {str(e)}") + return { + "success": False, + "message": "Failed to export BOM", + "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: + writer = csv.DictWriter(f, fieldnames=components[0].keys()) + writer.writeheader() + writer.writerows(components) + + 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") + for key, value in comp.items(): + elem = ET.SubElement(comp_elem, key) + elem.text = str(value) + tree = ET.ElementTree(root) + 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""" + html = ["Bill of Materials"] + html.append("") + # Headers + for key in components[0].keys(): + html.append(f"") + html.append("") + # Data + for comp in components: + html.append("") + for value in comp.values(): + html.append(f"") + html.append("") + html.append("
{key}
{value}
") + 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: + json.dump({"components": components}, f, indent=2) + + def _find_kicad_cli(self) -> Optional[str]: + """Find kicad-cli executable in system PATH or common locations + + Returns: + Path to kicad-cli executable, or None if not found + """ + import platform + import shutil + + # Try system PATH first + cli_path = shutil.which("kicad-cli") + if cli_path: + return cli_path + + # Try platform-specific default locations + system = platform.system() + + if system == "Windows": + possible_paths = [ + r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe", + r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe", + r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe", + r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe", + ] + elif system == "Darwin": # macOS + possible_paths = [ + "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli", + "/usr/local/bin/kicad-cli", + ] + else: # Linux + possible_paths = [ + "/usr/bin/kicad-cli", + "/usr/local/bin/kicad-cli", + ] + + for path in possible_paths: + if os.path.exists(path): + return path + + return None + + def _dev_copy_mcp_log(self, output_dir: str) -> None: + """DEV MODE: Copy the MCP server log for the current session into the project folder. + + Activated by env var KICAD_MCP_DEV=1. + The log is placed alongside the Gerber output as: + /mcp_log_.txt + + Only lines from the current server session (today's date) are included + to keep the file focused on the relevant run. + """ + import platform + + # Resolve Claude log path per platform + system = platform.system() + if system == "Windows": + log_dir = os.path.join(os.environ.get("APPDATA", ""), "Claude", "logs") + elif system == "Darwin": + log_dir = os.path.expanduser("~/Library/Logs/Claude") + else: + log_dir = os.path.expanduser("~/.config/Claude/logs") + + log_src = os.path.join(log_dir, "mcp-server-kicad.log") + if not os.path.exists(log_src): + logger.warning(f"[DEV] MCP log not found at: {log_src}") + return + + # Project dir = parent of outputDir (the Gerber subfolder) + project_dir = os.path.dirname(output_dir) + + # Extract only lines from the current session start (find last "Initializing server") + with open(log_src, "r", encoding="utf-8", errors="replace") as f: + all_lines = f.readlines() + + # Find last occurrence of server start so we get only the current run + session_start = 0 + for i, line in enumerate(all_lines): + if "Initializing server" in line: + session_start = i + + session_lines = all_lines[session_start:] + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + from pathlib import Path + + logs_dir = Path(project_dir) / "logs" + logs_dir.mkdir(exist_ok=True) + dest = str(logs_dir / f"mcp_log_{timestamp}.txt") + with open(dest, "w", encoding="utf-8") as f: + f.writelines(session_lines) + + logger.info(f"[DEV] MCP session log saved to: {dest} ({len(session_lines)} lines)") diff --git a/python/commands/jlcpcb.py b/python/commands/jlcpcb.py index 2cf9cad..7c02ca1 100644 --- a/python/commands/jlcpcb.py +++ b/python/commands/jlcpcb.py @@ -1,303 +1,303 @@ -""" -JLCPCB API client for fetching parts data - -Handles authentication and downloading the JLCPCB parts library -for integration with KiCAD component selection. -""" - -import base64 -import hashlib -import hmac -import json -import logging -import os -import secrets -import string -import time -from pathlib import Path -from typing import Callable, Dict, List, Optional - -import requests - -logger = logging.getLogger("kicad_interface") - - -class JLCPCBClient: - """ - Client for JLCPCB API - - Handles HMAC-SHA256 signature-based authentication and fetching - the complete parts library from JLCPCB's external API. - """ - - BASE_URL = "https://jlcpcb.com/external" - - def __init__( - self, - app_id: Optional[str] = None, - access_key: Optional[str] = None, - secret_key: Optional[str] = None, - ): - """ - Initialize JLCPCB API client - - Args: - app_id: JLCPCB App ID (or reads from JLCPCB_APP_ID env var) - access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var) - secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var) - """ - self.app_id = app_id or os.getenv("JLCPCB_APP_ID") - self.access_key = access_key or os.getenv("JLCPCB_API_KEY") - self.secret_key = secret_key or os.getenv("JLCPCB_API_SECRET") - - if not self.app_id or not self.access_key or not self.secret_key: - logger.warning( - "JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables." - ) - - @staticmethod - def _generate_nonce() -> str: - """Generate a 32-character random nonce""" - chars = string.ascii_letters + string.digits - return "".join(secrets.choice(chars) for _ in range(32)) - - def _build_signature_string( - self, method: str, path: str, timestamp: int, nonce: str, body: str - ) -> str: - """ - Build the signature string according to JLCPCB spec - - Format: - \n - \n - \n - \n - \n - - Args: - method: HTTP method (GET, POST, etc.) - path: Request path with query params - timestamp: Unix timestamp in seconds - nonce: 32-character random string - body: Request body (empty string for GET) - - Returns: - Signature string - """ - return f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}\n" - - def _sign(self, signature_string: str) -> str: - """ - Sign the signature string with HMAC-SHA256 - - Args: - signature_string: The string to sign - - Returns: - Base64-encoded signature - """ - signature_bytes = hmac.new( - self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256 - ).digest() - return base64.b64encode(signature_bytes).decode("utf-8") - - def _get_auth_header(self, method: str, path: str, body: str = "") -> str: - """ - Generate the Authorization header for JLCPCB API requests - - Args: - method: HTTP method (GET, POST, etc.) - path: Request path with query params - body: Request body JSON string (empty for GET) - - Returns: - Authorization header value - """ - if not self.app_id or not self.access_key or not self.secret_key: - raise Exception( - "JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables." - ) - - nonce = self._generate_nonce() - timestamp = int(time.time()) - - signature_string = self._build_signature_string(method, path, timestamp, nonce, body) - signature = self._sign(signature_string) - - logger.debug(f"Signature string:\n{repr(signature_string)}") - logger.debug(f"Signature: {signature}") - logger.debug( - f'Auth header: JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"' - ) - - return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"' - - def fetch_parts_page(self, last_key: Optional[str] = None) -> Dict: - """ - Fetch one page of parts from JLCPCB API - - Args: - last_key: Pagination key from previous response (None for first page) - - Returns: - Response dict with parts data and pagination info - """ - path = "/component/getComponentInfos" - - payload = {} - if last_key: - payload["lastKey"] = last_key - - # Convert payload to JSON string for signing - # For POST requests, we always send JSON, even if empty dict - body_str = json.dumps(payload, separators=(",", ":")) - - # Generate authorization header - auth_header = self._get_auth_header("POST", path, body_str) - - headers = {"Authorization": auth_header, "Content-Type": "application/json"} - - try: - response = requests.post( - f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60 - ) - - logger.debug(f"Response status: {response.status_code}") - logger.debug(f"Response headers: {response.headers}") - logger.debug(f"Response text: {response.text}") - - response.raise_for_status() - data = response.json() - - if data.get("code") != 200: - raise Exception( - f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}" - ) - - return data["data"] - - except requests.exceptions.RequestException as e: - logger.error(f"Failed to fetch parts page: {e}") - raise Exception(f"JLCPCB API request failed: {e}") - - def download_full_database( - self, callback: Optional[Callable[[int, int, str], None]] = None - ) -> List[Dict]: - """ - Download entire parts library from JLCPCB - - Args: - callback: Optional progress callback function(current_page, total_parts, status_msg) - - Returns: - List of all parts - """ - all_parts = [] - last_key = None - page = 0 - - logger.info("Starting full JLCPCB parts database download...") - - while True: - page += 1 - - try: - data = self.fetch_parts_page(last_key) - - parts = data.get("componentInfos", []) - all_parts.extend(parts) - - last_key = data.get("lastKey") - - if callback: - callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...") - else: - logger.info(f"Page {page}: Downloaded {len(all_parts)} parts so far...") - - # Check if there are more pages - if not last_key or len(parts) == 0: - break - - # Rate limiting - be nice to the API - time.sleep(0.5) - - except Exception as e: - logger.error(f"Error downloading parts at page {page}: {e}") - if len(all_parts) > 0: - logger.warning(f"Partial download available: {len(all_parts)} parts") - return all_parts - else: - raise - - logger.info(f"Download complete: {len(all_parts)} parts retrieved") - return all_parts - - def get_part_by_lcsc(self, lcsc_number: str) -> Optional[Dict]: - """ - Get detailed information for a specific LCSC part number - - Note: This uses the same endpoint as fetching parts, as JLCPCB doesn't - have a dedicated single-part endpoint. In practice, you should use - the local database after initial download. - - Args: - lcsc_number: LCSC part number (e.g., "C25804") - - Returns: - Part info dict or None if not found - """ - # For now, this would require searching through pages - # In practice, you'd use the local database - logger.warning("get_part_by_lcsc should use local database, not API") - return None - - -def test_jlcpcb_connection( - app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None -) -> bool: - """ - Test JLCPCB API connection - - Args: - app_id: Optional App ID (uses env var if not provided) - access_key: Optional Access Key (uses env var if not provided) - secret_key: Optional Secret Key (uses env var if not provided) - - Returns: - True if connection successful, False otherwise - """ - try: - client = JLCPCBClient(app_id, access_key, secret_key) - # Test by fetching first page - data = client.fetch_parts_page() - logger.info("JLCPCB API connection test successful") - return True - except Exception as e: - logger.error(f"JLCPCB API connection test failed: {e}") - return False - - -if __name__ == "__main__": - # Test the JLCPCB client - logging.basicConfig(level=logging.INFO) - - print("Testing JLCPCB API connection...") - if test_jlcpcb_connection(): - print("✓ Connection successful!") - - client = JLCPCBClient() - print("\nFetching first page of parts...") - data = client.fetch_parts_page() - parts = data.get("componentInfos", []) - print(f"✓ Retrieved {len(parts)} parts in first page") - - if parts: - print(f"\nExample part:") - part = parts[0] - print(f" LCSC: {part.get('componentCode')}") - print(f" MFR Part: {part.get('componentModelEn')}") - print(f" Category: {part.get('firstSortName')} / {part.get('secondSortName')}") - print(f" Package: {part.get('componentSpecificationEn')}") - print(f" Stock: {part.get('stockCount')}") - else: - print("✗ Connection failed. Check your API credentials.") +""" +JLCPCB API client for fetching parts data + +Handles authentication and downloading the JLCPCB parts library +for integration with KiCAD component selection. +""" + +import base64 +import hashlib +import hmac +import json +import logging +import os +import secrets +import string +import time +from pathlib import Path +from typing import Callable, Dict, List, Optional + +import requests + +logger = logging.getLogger("kicad_interface") + + +class JLCPCBClient: + """ + Client for JLCPCB API + + Handles HMAC-SHA256 signature-based authentication and fetching + the complete parts library from JLCPCB's external API. + """ + + BASE_URL = "https://jlcpcb.com/external" + + def __init__( + self, + app_id: Optional[str] = None, + access_key: Optional[str] = None, + secret_key: Optional[str] = None, + ): + """ + Initialize JLCPCB API client + + Args: + app_id: JLCPCB App ID (or reads from JLCPCB_APP_ID env var) + access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var) + secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var) + """ + self.app_id = app_id or os.getenv("JLCPCB_APP_ID") + self.access_key = access_key or os.getenv("JLCPCB_API_KEY") + self.secret_key = secret_key or os.getenv("JLCPCB_API_SECRET") + + if not self.app_id or not self.access_key or not self.secret_key: + logger.warning( + "JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables." + ) + + @staticmethod + def _generate_nonce() -> str: + """Generate a 32-character random nonce""" + chars = string.ascii_letters + string.digits + return "".join(secrets.choice(chars) for _ in range(32)) + + def _build_signature_string( + self, method: str, path: str, timestamp: int, nonce: str, body: str + ) -> str: + """ + Build the signature string according to JLCPCB spec + + Format: + \n + \n + \n + \n + \n + + Args: + method: HTTP method (GET, POST, etc.) + path: Request path with query params + timestamp: Unix timestamp in seconds + nonce: 32-character random string + body: Request body (empty string for GET) + + Returns: + Signature string + """ + return f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}\n" + + def _sign(self, signature_string: str) -> str: + """ + Sign the signature string with HMAC-SHA256 + + Args: + signature_string: The string to sign + + Returns: + Base64-encoded signature + """ + signature_bytes = hmac.new( + self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256 + ).digest() + return base64.b64encode(signature_bytes).decode("utf-8") + + def _get_auth_header(self, method: str, path: str, body: str = "") -> str: + """ + Generate the Authorization header for JLCPCB API requests + + Args: + method: HTTP method (GET, POST, etc.) + path: Request path with query params + body: Request body JSON string (empty for GET) + + Returns: + Authorization header value + """ + if not self.app_id or not self.access_key or not self.secret_key: + raise Exception( + "JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables." + ) + + nonce = self._generate_nonce() + timestamp = int(time.time()) + + signature_string = self._build_signature_string(method, path, timestamp, nonce, body) + signature = self._sign(signature_string) + + logger.debug(f"Signature string:\n{repr(signature_string)}") + logger.debug(f"Signature: {signature}") + logger.debug( + f'Auth header: JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"' + ) + + return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"' + + def fetch_parts_page(self, last_key: Optional[str] = None) -> Dict: + """ + Fetch one page of parts from JLCPCB API + + Args: + last_key: Pagination key from previous response (None for first page) + + Returns: + Response dict with parts data and pagination info + """ + path = "/component/getComponentInfos" + + payload = {} + if last_key: + payload["lastKey"] = last_key + + # Convert payload to JSON string for signing + # For POST requests, we always send JSON, even if empty dict + body_str = json.dumps(payload, separators=(",", ":")) + + # Generate authorization header + auth_header = self._get_auth_header("POST", path, body_str) + + headers = {"Authorization": auth_header, "Content-Type": "application/json"} + + try: + response = requests.post( + f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60 + ) + + logger.debug(f"Response status: {response.status_code}") + logger.debug(f"Response headers: {response.headers}") + logger.debug(f"Response text: {response.text}") + + response.raise_for_status() + data = response.json() + + if data.get("code") != 200: + raise Exception( + f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}" + ) + + return data["data"] + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to fetch parts page: {e}") + raise Exception(f"JLCPCB API request failed: {e}") + + def download_full_database( + self, callback: Optional[Callable[[int, int, str], None]] = None + ) -> List[Dict]: + """ + Download entire parts library from JLCPCB + + Args: + callback: Optional progress callback function(current_page, total_parts, status_msg) + + Returns: + List of all parts + """ + all_parts = [] + last_key = None + page = 0 + + logger.info("Starting full JLCPCB parts database download...") + + while True: + page += 1 + + try: + data = self.fetch_parts_page(last_key) + + parts = data.get("componentInfos", []) + all_parts.extend(parts) + + last_key = data.get("lastKey") + + if callback: + callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...") + else: + logger.info(f"Page {page}: Downloaded {len(all_parts)} parts so far...") + + # Check if there are more pages + if not last_key or len(parts) == 0: + break + + # Rate limiting - be nice to the API + time.sleep(0.5) + + except Exception as e: + logger.error(f"Error downloading parts at page {page}: {e}") + if len(all_parts) > 0: + logger.warning(f"Partial download available: {len(all_parts)} parts") + return all_parts + else: + raise + + logger.info(f"Download complete: {len(all_parts)} parts retrieved") + return all_parts + + def get_part_by_lcsc(self, lcsc_number: str) -> Optional[Dict]: + """ + Get detailed information for a specific LCSC part number + + Note: This uses the same endpoint as fetching parts, as JLCPCB doesn't + have a dedicated single-part endpoint. In practice, you should use + the local database after initial download. + + Args: + lcsc_number: LCSC part number (e.g., "C25804") + + Returns: + Part info dict or None if not found + """ + # For now, this would require searching through pages + # In practice, you'd use the local database + logger.warning("get_part_by_lcsc should use local database, not API") + return None + + +def test_jlcpcb_connection( + app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None +) -> bool: + """ + Test JLCPCB API connection + + Args: + app_id: Optional App ID (uses env var if not provided) + access_key: Optional Access Key (uses env var if not provided) + secret_key: Optional Secret Key (uses env var if not provided) + + Returns: + True if connection successful, False otherwise + """ + try: + client = JLCPCBClient(app_id, access_key, secret_key) + # Test by fetching first page + data = client.fetch_parts_page() + logger.info("JLCPCB API connection test successful") + return True + except Exception as e: + logger.error(f"JLCPCB API connection test failed: {e}") + return False + + +if __name__ == "__main__": + # Test the JLCPCB client + logging.basicConfig(level=logging.INFO) + + print("Testing JLCPCB API connection...") + if test_jlcpcb_connection(): + print("✓ Connection successful!") + + client = JLCPCBClient() + print("\nFetching first page of parts...") + data = client.fetch_parts_page() + parts = data.get("componentInfos", []) + print(f"✓ Retrieved {len(parts)} parts in first page") + + if parts: + print(f"\nExample part:") + part = parts[0] + print(f" LCSC: {part.get('componentCode')}") + print(f" MFR Part: {part.get('componentModelEn')}") + print(f" Category: {part.get('firstSortName')} / {part.get('secondSortName')}") + print(f" Package: {part.get('componentSpecificationEn')}") + print(f" Stock: {part.get('stockCount')}") + else: + print("✗ Connection failed. Check your API credentials.") diff --git a/python/commands/jlcpcb_parts.py b/python/commands/jlcpcb_parts.py index 76f6f4e..16de179 100644 --- a/python/commands/jlcpcb_parts.py +++ b/python/commands/jlcpcb_parts.py @@ -1,501 +1,501 @@ -""" -JLCPCB Parts Database Manager - -Manages local SQLite database of JLCPCB parts for fast searching -and component selection. -""" - -import json -import logging -import os -import sqlite3 -from datetime import datetime -from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple - -logger = logging.getLogger("kicad_interface") - - -class JLCPCBPartsManager: - """ - Manages local database of JLCPCB parts - - Provides fast parametric search, filtering, and package-to-footprint mapping. - """ - - def __init__(self, db_path: Optional[str] = None): - """ - Initialize parts database manager - - Args: - db_path: Path to SQLite database file (default: data/jlcpcb_parts.db) - """ - if db_path is None: - # Default to data directory in project root - project_root = Path(__file__).parent.parent.parent - data_dir = project_root / "data" - data_dir.mkdir(exist_ok=True) - db_path = str(data_dir / "jlcpcb_parts.db") - - self.db_path = db_path - self.conn: Optional[sqlite3.Connection] = None - self._init_database() - - def _init_database(self) -> None: - """Initialize SQLite database with schema""" - self.conn = sqlite3.connect(self.db_path) - self.conn.row_factory = sqlite3.Row # Return rows as dicts - - cursor = self.conn.cursor() - - # Create components table - cursor.execute(""" - CREATE TABLE IF NOT EXISTS components ( - lcsc TEXT PRIMARY KEY, - category TEXT, - subcategory TEXT, - mfr_part TEXT, - package TEXT, - solder_joints INTEGER, - manufacturer TEXT, - library_type TEXT, - description TEXT, - datasheet TEXT, - stock INTEGER, - price_json TEXT, - last_updated INTEGER - ) - """) - - # Create indexes for fast searching - cursor.execute( - "CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)" - ) - cursor.execute("CREATE INDEX IF NOT EXISTS idx_package ON components(package)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_manufacturer ON components(manufacturer)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_type ON components(library_type)") - cursor.execute("CREATE INDEX IF NOT EXISTS idx_mfr_part ON components(mfr_part)") - - # Full-text search index for descriptions - cursor.execute(""" - CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5( - lcsc, - description, - mfr_part, - manufacturer, - content=components - ) - """) - - self.conn.commit() - logger.info(f"Initialized JLCPCB parts database at {self.db_path}") - - def import_parts( - self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None - ) -> None: - """ - Import parts into database from JLCPCB API response - - Args: - parts: List of part dicts from JLCPCB API - progress_callback: Optional callback(current, total, message) - """ - cursor = self.conn.cursor() - imported = 0 - skipped = 0 - - for i, part in enumerate(parts): - try: - # Extract price breaks - price_json = json.dumps(part.get("prices", [])) - - # Determine library type - library_type = self._determine_library_type(part) - - cursor.execute( - """ - INSERT OR REPLACE INTO components ( - lcsc, category, subcategory, mfr_part, package, - solder_joints, manufacturer, library_type, description, - datasheet, stock, price_json, last_updated - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - part.get("componentCode"), # lcsc - part.get("firstSortName"), # category - part.get("secondSortName"), # subcategory - part.get("componentModelEn"), # mfr_part - part.get("componentSpecificationEn"), # package - part.get("soldPoint"), # solder_joints - part.get("componentBrandEn"), # manufacturer - library_type, # library_type - part.get("describe"), # description - part.get("dataManualUrl"), # datasheet - part.get("stockCount", 0), # stock - price_json, # price_json - int(datetime.now().timestamp()), # last_updated - ), - ) - - imported += 1 - - if progress_callback and (i + 1) % 1000 == 0: - progress_callback(i + 1, len(parts), f"Imported {imported} parts...") - - except Exception as e: - logger.error(f"Error importing part {part.get('componentCode')}: {e}") - skipped += 1 - - # Update FTS index - cursor.execute(""" - INSERT INTO components_fts(components_fts, rowid, lcsc, description, mfr_part, manufacturer) - SELECT 'rebuild', rowid, lcsc, description, mfr_part, manufacturer FROM components - """) - - self.conn.commit() - logger.info(f"Import complete: {imported} parts imported, {skipped} skipped") - - def _determine_library_type(self, part: Dict) -> str: - """Determine if part is Basic, Extended, or Preferred""" - # JLCPCB API should provide this, but if not, we infer from assembly type - assembly_type = part.get("assemblyType", "") - - if "Basic" in assembly_type or part.get("libraryType") == "base": - return "Basic" - elif "Extended" in assembly_type: - return "Extended" - elif "Prefer" in assembly_type: - return "Preferred" - else: - return "Extended" # Default to Extended - - def import_jlcsearch_parts( - self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None - ) -> None: - """ - Import parts into database from JLCSearch API response - - Args: - parts: List of part dicts from JLCSearch API - progress_callback: Optional callback(current, total, message) - """ - cursor = self.conn.cursor() - imported = 0 - skipped = 0 - - for i, part in enumerate(parts): - try: - # JLCSearch format is different from official API - # LCSC is an integer, we need to add 'C' prefix - lcsc = part.get("lcsc") - if isinstance(lcsc, int): - lcsc = f"C{lcsc}" - - # Build price JSON from jlcsearch single price - price = part.get("price") or part.get("price1") - price_json = json.dumps([{"qty": 1, "price": price}] if price else []) - - # Determine library type from is_basic flag - library_type = "Basic" if part.get("is_basic") else "Extended" - if part.get("is_preferred"): - library_type = "Preferred" - - # Extract description from various fields - description_parts = [] - if "resistance" in part: - description_parts.append(f"{part['resistance']}Ω") - if "capacitance" in part: - description_parts.append(f"{part['capacitance']}F") - if "tolerance_fraction" in part: - tol = part["tolerance_fraction"] * 100 - description_parts.append(f"±{tol}%") - if "power_watts" in part: - description_parts.append(f"{part['power_watts']}mW") - if "voltage" in part: - description_parts.append(f"{part['voltage']}V") - - description = part.get("description", " ".join(description_parts)) - - cursor.execute( - """ - INSERT OR REPLACE INTO components ( - lcsc, category, subcategory, mfr_part, package, - solder_joints, manufacturer, library_type, description, - datasheet, stock, price_json, last_updated - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, - ( - lcsc, # lcsc with C prefix - part.get("category", ""), # category - part.get("subcategory", ""), # subcategory - part.get("mfr", ""), # mfr_part - part.get("package", ""), # package - 0, # solder_joints (not in jlcsearch) - part.get("manufacturer", ""), # manufacturer - library_type, # library_type - description, # description - "", # datasheet (not in jlcsearch) - part.get("stock", 0), # stock - price_json, # price_json - int(datetime.now().timestamp()), # last_updated - ), - ) - - imported += 1 - - if progress_callback and (i + 1) % 1000 == 0: - progress_callback(i + 1, len(parts), f"Imported {imported} parts...") - - except Exception as e: - logger.error(f"Error importing part {part.get('lcsc')}: {e}") - skipped += 1 - - # Update FTS index - cursor.execute(""" - INSERT INTO components_fts(components_fts) - VALUES('rebuild') - """) - - self.conn.commit() - logger.info(f"Import complete: {imported} parts imported, {skipped} skipped") - - def search_parts( - self, - query: Optional[str] = None, - category: Optional[str] = None, - package: Optional[str] = None, - library_type: Optional[str] = None, - manufacturer: Optional[str] = None, - in_stock: bool = True, - limit: int = 20, - ) -> List[Dict]: - """ - Search for parts with filters - - Args: - query: Free-text search (searches description, mfr part, LCSC) - category: Filter by category name - package: Filter by package type - library_type: Filter by "Basic", "Extended", or "Preferred" - manufacturer: Filter by manufacturer name - in_stock: Only return parts with stock > 0 - limit: Maximum number of results - - Returns: - List of matching parts - """ - cursor = self.conn.cursor() - - # Build query - sql_parts = ["SELECT * FROM components WHERE 1=1"] - params = [] - - if query: - # Use FTS for text search - # Add prefix wildcard to each term for partial matching - # (e.g., "BQ25895" becomes "BQ25895*" so FTS matches "BQ25895RTWR") - fts_query = " ".join( - f"{term}*" if not term.endswith("*") else term for term in query.strip().split() - ) - sql_parts.append(""" - AND lcsc IN ( - SELECT lcsc FROM components_fts - WHERE components_fts MATCH ? - ) - """) - params.append(fts_query) - - if category: - sql_parts.append("AND category LIKE ?") - params.append(f"%{category}%") - - if package: - sql_parts.append("AND package LIKE ?") - params.append(f"%{package}%") - - if library_type: - sql_parts.append("AND library_type = ?") - params.append(library_type) - - if manufacturer: - sql_parts.append("AND manufacturer LIKE ?") - params.append(f"%{manufacturer}%") - - if in_stock: - sql_parts.append("AND stock > 0") - - sql_parts.append("LIMIT ?") - params.append(limit) - - sql = " ".join(sql_parts) - - try: - cursor.execute(sql, params) - rows = cursor.fetchall() - return [dict(row) for row in rows] - except Exception as e: - logger.error(f"Search error: {e}") - return [] - - def get_part_info(self, lcsc_number: str) -> Optional[Dict]: - """ - Get detailed information for specific LCSC part - - Args: - lcsc_number: LCSC part number (e.g., "C25804") - - Returns: - Part info dict or None if not found - """ - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM components WHERE lcsc = ?", (lcsc_number,)) - row = cursor.fetchone() - - if row: - part = dict(row) - # Parse price JSON - if part.get("price_json"): - try: - part["price_breaks"] = json.loads(part["price_json"]) - except: - part["price_breaks"] = [] - return part - return None - - def get_database_stats(self) -> Dict: - """Get statistics about the database""" - cursor = self.conn.cursor() - - cursor.execute("SELECT COUNT(*) as total FROM components") - total = cursor.fetchone()["total"] - - cursor.execute("SELECT COUNT(*) as basic FROM components WHERE library_type = 'Basic'") - basic = cursor.fetchone()["basic"] - - cursor.execute( - "SELECT COUNT(*) as extended FROM components WHERE library_type = 'Extended'" - ) - extended = cursor.fetchone()["extended"] - - cursor.execute("SELECT COUNT(*) as in_stock FROM components WHERE stock > 0") - in_stock = cursor.fetchone()["in_stock"] - - return { - "total_parts": total, - "basic_parts": basic, - "extended_parts": extended, - "in_stock": in_stock, - "db_path": self.db_path, - } - - def map_package_to_footprint(self, package: str) -> List[str]: - """ - Map JLCPCB package name to KiCAD footprint(s) - - Args: - package: JLCPCB package name (e.g., "0603", "SOT-23") - - Returns: - List of possible KiCAD footprint library refs - """ - # Load mapping from JSON file or use defaults - mappings = { - "0402": [ - "Resistor_SMD:R_0402_1005Metric", - "Capacitor_SMD:C_0402_1005Metric", - "LED_SMD:LED_0402_1005Metric", - ], - "0603": [ - "Resistor_SMD:R_0603_1608Metric", - "Capacitor_SMD:C_0603_1608Metric", - "LED_SMD:LED_0603_1608Metric", - ], - "0805": ["Resistor_SMD:R_0805_2012Metric", "Capacitor_SMD:C_0805_2012Metric"], - "1206": ["Resistor_SMD:R_1206_3216Metric", "Capacitor_SMD:C_1206_3216Metric"], - "SOT-23": ["Package_TO_SOT_SMD:SOT-23", "Package_TO_SOT_SMD:SOT-23-3"], - "SOT-23-5": ["Package_TO_SOT_SMD:SOT-23-5"], - "SOT-23-6": ["Package_TO_SOT_SMD:SOT-23-6"], - "SOIC-8": ["Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"], - "SOIC-16": ["Package_SO:SOIC-16_3.9x9.9mm_P1.27mm"], - "QFN-20": ["Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm"], - "QFN-32": ["Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm"], - } - - # Normalize package name - package_normalized = package.strip().upper() - - for key, footprints in mappings.items(): - if key.upper() in package_normalized: - return footprints - - return [] - - def suggest_alternatives(self, lcsc_number: str, limit: int = 5) -> List[Dict]: - """ - Find alternative parts similar to the given LCSC number - - Prioritizes: cheaper price, higher stock, Basic library type - - Args: - lcsc_number: Reference LCSC part number - limit: Maximum alternatives to return - - Returns: - List of alternative parts - """ - part = self.get_part_info(lcsc_number) - if not part: - return [] - - # Search for parts in same category with same package - alternatives = self.search_parts( - category=part["subcategory"], package=part["package"], in_stock=True, limit=limit * 3 - ) - - # Filter out the original part - alternatives = [p for p in alternatives if p["lcsc"] != lcsc_number] - - # Sort by: Basic first, then by price, then by stock - def sort_key(p: Dict[str, Any]) -> Tuple[int, float, int]: - is_basic = 1 if p.get("library_type") == "Basic" else 0 - try: - prices = json.loads(p.get("price_json", "[]")) - price = float(prices[0].get("price", 999)) if prices else 999 - except: - price = 999 - stock = p.get("stock", 0) - - return (-is_basic, price, -stock) - - alternatives.sort(key=sort_key) - - return alternatives[:limit] - - def close(self) -> None: - """Close database connection""" - if self.conn: - self.conn.close() - - -if __name__ == "__main__": - # Test the parts manager - logging.basicConfig(level=logging.INFO) - - manager = JLCPCBPartsManager() - - # Get stats - stats = manager.get_database_stats() - print(f"\nDatabase Statistics:") - print(f" Total parts: {stats['total_parts']}") - print(f" Basic parts: {stats['basic_parts']}") - print(f" Extended parts: {stats['extended_parts']}") - print(f" In stock: {stats['in_stock']}") - print(f" Database: {stats['db_path']}") - - if stats["total_parts"] > 0: - print("\nSearching for '10k resistor'...") - results = manager.search_parts(query="10k resistor", limit=5) - for part in results: - print( - f" {part['lcsc']}: {part['mfr_part']} - {part['description']} ({part['library_type']})" - ) +""" +JLCPCB Parts Database Manager + +Manages local SQLite database of JLCPCB parts for fast searching +and component selection. +""" + +import json +import logging +import os +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Tuple + +logger = logging.getLogger("kicad_interface") + + +class JLCPCBPartsManager: + """ + Manages local database of JLCPCB parts + + Provides fast parametric search, filtering, and package-to-footprint mapping. + """ + + def __init__(self, db_path: Optional[str] = None): + """ + Initialize parts database manager + + Args: + db_path: Path to SQLite database file (default: data/jlcpcb_parts.db) + """ + if db_path is None: + # Default to data directory in project root + project_root = Path(__file__).parent.parent.parent + data_dir = project_root / "data" + data_dir.mkdir(exist_ok=True) + db_path = str(data_dir / "jlcpcb_parts.db") + + self.db_path = db_path + self.conn: Optional[sqlite3.Connection] = None + self._init_database() + + def _init_database(self) -> None: + """Initialize SQLite database with schema""" + self.conn = sqlite3.connect(self.db_path) + self.conn.row_factory = sqlite3.Row # Return rows as dicts + + cursor = self.conn.cursor() + + # Create components table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS components ( + lcsc TEXT PRIMARY KEY, + category TEXT, + subcategory TEXT, + mfr_part TEXT, + package TEXT, + solder_joints INTEGER, + manufacturer TEXT, + library_type TEXT, + description TEXT, + datasheet TEXT, + stock INTEGER, + price_json TEXT, + last_updated INTEGER + ) + """) + + # Create indexes for fast searching + cursor.execute( + "CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)" + ) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_package ON components(package)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_manufacturer ON components(manufacturer)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_type ON components(library_type)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_mfr_part ON components(mfr_part)") + + # Full-text search index for descriptions + cursor.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5( + lcsc, + description, + mfr_part, + manufacturer, + content=components + ) + """) + + self.conn.commit() + logger.info(f"Initialized JLCPCB parts database at {self.db_path}") + + def import_parts( + self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None + ) -> None: + """ + Import parts into database from JLCPCB API response + + Args: + parts: List of part dicts from JLCPCB API + progress_callback: Optional callback(current, total, message) + """ + cursor = self.conn.cursor() + imported = 0 + skipped = 0 + + for i, part in enumerate(parts): + try: + # Extract price breaks + price_json = json.dumps(part.get("prices", [])) + + # Determine library type + library_type = self._determine_library_type(part) + + cursor.execute( + """ + INSERT OR REPLACE INTO components ( + lcsc, category, subcategory, mfr_part, package, + solder_joints, manufacturer, library_type, description, + datasheet, stock, price_json, last_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + part.get("componentCode"), # lcsc + part.get("firstSortName"), # category + part.get("secondSortName"), # subcategory + part.get("componentModelEn"), # mfr_part + part.get("componentSpecificationEn"), # package + part.get("soldPoint"), # solder_joints + part.get("componentBrandEn"), # manufacturer + library_type, # library_type + part.get("describe"), # description + part.get("dataManualUrl"), # datasheet + part.get("stockCount", 0), # stock + price_json, # price_json + int(datetime.now().timestamp()), # last_updated + ), + ) + + imported += 1 + + if progress_callback and (i + 1) % 1000 == 0: + progress_callback(i + 1, len(parts), f"Imported {imported} parts...") + + except Exception as e: + logger.error(f"Error importing part {part.get('componentCode')}: {e}") + skipped += 1 + + # Update FTS index + cursor.execute(""" + INSERT INTO components_fts(components_fts, rowid, lcsc, description, mfr_part, manufacturer) + SELECT 'rebuild', rowid, lcsc, description, mfr_part, manufacturer FROM components + """) + + self.conn.commit() + logger.info(f"Import complete: {imported} parts imported, {skipped} skipped") + + def _determine_library_type(self, part: Dict) -> str: + """Determine if part is Basic, Extended, or Preferred""" + # JLCPCB API should provide this, but if not, we infer from assembly type + assembly_type = part.get("assemblyType", "") + + if "Basic" in assembly_type or part.get("libraryType") == "base": + return "Basic" + elif "Extended" in assembly_type: + return "Extended" + elif "Prefer" in assembly_type: + return "Preferred" + else: + return "Extended" # Default to Extended + + def import_jlcsearch_parts( + self, parts: List[Dict], progress_callback: Optional[Callable[..., Any]] = None + ) -> None: + """ + Import parts into database from JLCSearch API response + + Args: + parts: List of part dicts from JLCSearch API + progress_callback: Optional callback(current, total, message) + """ + cursor = self.conn.cursor() + imported = 0 + skipped = 0 + + for i, part in enumerate(parts): + try: + # JLCSearch format is different from official API + # LCSC is an integer, we need to add 'C' prefix + lcsc = part.get("lcsc") + if isinstance(lcsc, int): + lcsc = f"C{lcsc}" + + # Build price JSON from jlcsearch single price + price = part.get("price") or part.get("price1") + price_json = json.dumps([{"qty": 1, "price": price}] if price else []) + + # Determine library type from is_basic flag + library_type = "Basic" if part.get("is_basic") else "Extended" + if part.get("is_preferred"): + library_type = "Preferred" + + # Extract description from various fields + description_parts = [] + if "resistance" in part: + description_parts.append(f"{part['resistance']}Ω") + if "capacitance" in part: + description_parts.append(f"{part['capacitance']}F") + if "tolerance_fraction" in part: + tol = part["tolerance_fraction"] * 100 + description_parts.append(f"±{tol}%") + if "power_watts" in part: + description_parts.append(f"{part['power_watts']}mW") + if "voltage" in part: + description_parts.append(f"{part['voltage']}V") + + description = part.get("description", " ".join(description_parts)) + + cursor.execute( + """ + INSERT OR REPLACE INTO components ( + lcsc, category, subcategory, mfr_part, package, + solder_joints, manufacturer, library_type, description, + datasheet, stock, price_json, last_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + lcsc, # lcsc with C prefix + part.get("category", ""), # category + part.get("subcategory", ""), # subcategory + part.get("mfr", ""), # mfr_part + part.get("package", ""), # package + 0, # solder_joints (not in jlcsearch) + part.get("manufacturer", ""), # manufacturer + library_type, # library_type + description, # description + "", # datasheet (not in jlcsearch) + part.get("stock", 0), # stock + price_json, # price_json + int(datetime.now().timestamp()), # last_updated + ), + ) + + imported += 1 + + if progress_callback and (i + 1) % 1000 == 0: + progress_callback(i + 1, len(parts), f"Imported {imported} parts...") + + except Exception as e: + logger.error(f"Error importing part {part.get('lcsc')}: {e}") + skipped += 1 + + # Update FTS index + cursor.execute(""" + INSERT INTO components_fts(components_fts) + VALUES('rebuild') + """) + + self.conn.commit() + logger.info(f"Import complete: {imported} parts imported, {skipped} skipped") + + def search_parts( + self, + query: Optional[str] = None, + category: Optional[str] = None, + package: Optional[str] = None, + library_type: Optional[str] = None, + manufacturer: Optional[str] = None, + in_stock: bool = True, + limit: int = 20, + ) -> List[Dict]: + """ + Search for parts with filters + + Args: + query: Free-text search (searches description, mfr part, LCSC) + category: Filter by category name + package: Filter by package type + library_type: Filter by "Basic", "Extended", or "Preferred" + manufacturer: Filter by manufacturer name + in_stock: Only return parts with stock > 0 + limit: Maximum number of results + + Returns: + List of matching parts + """ + cursor = self.conn.cursor() + + # Build query + sql_parts = ["SELECT * FROM components WHERE 1=1"] + params = [] + + if query: + # Use FTS for text search + # Add prefix wildcard to each term for partial matching + # (e.g., "BQ25895" becomes "BQ25895*" so FTS matches "BQ25895RTWR") + fts_query = " ".join( + f"{term}*" if not term.endswith("*") else term for term in query.strip().split() + ) + sql_parts.append(""" + AND lcsc IN ( + SELECT lcsc FROM components_fts + WHERE components_fts MATCH ? + ) + """) + params.append(fts_query) + + if category: + sql_parts.append("AND category LIKE ?") + params.append(f"%{category}%") + + if package: + sql_parts.append("AND package LIKE ?") + params.append(f"%{package}%") + + if library_type: + sql_parts.append("AND library_type = ?") + params.append(library_type) + + if manufacturer: + sql_parts.append("AND manufacturer LIKE ?") + params.append(f"%{manufacturer}%") + + if in_stock: + sql_parts.append("AND stock > 0") + + sql_parts.append("LIMIT ?") + params.append(limit) + + sql = " ".join(sql_parts) + + try: + cursor.execute(sql, params) + rows = cursor.fetchall() + return [dict(row) for row in rows] + except Exception as e: + logger.error(f"Search error: {e}") + return [] + + def get_part_info(self, lcsc_number: str) -> Optional[Dict]: + """ + Get detailed information for specific LCSC part + + Args: + lcsc_number: LCSC part number (e.g., "C25804") + + Returns: + Part info dict or None if not found + """ + cursor = self.conn.cursor() + cursor.execute("SELECT * FROM components WHERE lcsc = ?", (lcsc_number,)) + row = cursor.fetchone() + + if row: + part = dict(row) + # Parse price JSON + if part.get("price_json"): + try: + part["price_breaks"] = json.loads(part["price_json"]) + except: + part["price_breaks"] = [] + return part + return None + + def get_database_stats(self) -> Dict: + """Get statistics about the database""" + cursor = self.conn.cursor() + + cursor.execute("SELECT COUNT(*) as total FROM components") + total = cursor.fetchone()["total"] + + cursor.execute("SELECT COUNT(*) as basic FROM components WHERE library_type = 'Basic'") + basic = cursor.fetchone()["basic"] + + cursor.execute( + "SELECT COUNT(*) as extended FROM components WHERE library_type = 'Extended'" + ) + extended = cursor.fetchone()["extended"] + + cursor.execute("SELECT COUNT(*) as in_stock FROM components WHERE stock > 0") + in_stock = cursor.fetchone()["in_stock"] + + return { + "total_parts": total, + "basic_parts": basic, + "extended_parts": extended, + "in_stock": in_stock, + "db_path": self.db_path, + } + + def map_package_to_footprint(self, package: str) -> List[str]: + """ + Map JLCPCB package name to KiCAD footprint(s) + + Args: + package: JLCPCB package name (e.g., "0603", "SOT-23") + + Returns: + List of possible KiCAD footprint library refs + """ + # Load mapping from JSON file or use defaults + mappings = { + "0402": [ + "Resistor_SMD:R_0402_1005Metric", + "Capacitor_SMD:C_0402_1005Metric", + "LED_SMD:LED_0402_1005Metric", + ], + "0603": [ + "Resistor_SMD:R_0603_1608Metric", + "Capacitor_SMD:C_0603_1608Metric", + "LED_SMD:LED_0603_1608Metric", + ], + "0805": ["Resistor_SMD:R_0805_2012Metric", "Capacitor_SMD:C_0805_2012Metric"], + "1206": ["Resistor_SMD:R_1206_3216Metric", "Capacitor_SMD:C_1206_3216Metric"], + "SOT-23": ["Package_TO_SOT_SMD:SOT-23", "Package_TO_SOT_SMD:SOT-23-3"], + "SOT-23-5": ["Package_TO_SOT_SMD:SOT-23-5"], + "SOT-23-6": ["Package_TO_SOT_SMD:SOT-23-6"], + "SOIC-8": ["Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"], + "SOIC-16": ["Package_SO:SOIC-16_3.9x9.9mm_P1.27mm"], + "QFN-20": ["Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm"], + "QFN-32": ["Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm"], + } + + # Normalize package name + package_normalized = package.strip().upper() + + for key, footprints in mappings.items(): + if key.upper() in package_normalized: + return footprints + + return [] + + def suggest_alternatives(self, lcsc_number: str, limit: int = 5) -> List[Dict]: + """ + Find alternative parts similar to the given LCSC number + + Prioritizes: cheaper price, higher stock, Basic library type + + Args: + lcsc_number: Reference LCSC part number + limit: Maximum alternatives to return + + Returns: + List of alternative parts + """ + part = self.get_part_info(lcsc_number) + if not part: + return [] + + # Search for parts in same category with same package + alternatives = self.search_parts( + category=part["subcategory"], package=part["package"], in_stock=True, limit=limit * 3 + ) + + # Filter out the original part + alternatives = [p for p in alternatives if p["lcsc"] != lcsc_number] + + # Sort by: Basic first, then by price, then by stock + def sort_key(p: Dict[str, Any]) -> Tuple[int, float, int]: + is_basic = 1 if p.get("library_type") == "Basic" else 0 + try: + prices = json.loads(p.get("price_json", "[]")) + price = float(prices[0].get("price", 999)) if prices else 999 + except: + price = 999 + stock = p.get("stock", 0) + + return (-is_basic, price, -stock) + + alternatives.sort(key=sort_key) + + return alternatives[:limit] + + def close(self) -> None: + """Close database connection""" + if self.conn: + self.conn.close() + + +if __name__ == "__main__": + # Test the parts manager + logging.basicConfig(level=logging.INFO) + + manager = JLCPCBPartsManager() + + # Get stats + stats = manager.get_database_stats() + print(f"\nDatabase Statistics:") + print(f" Total parts: {stats['total_parts']}") + print(f" Basic parts: {stats['basic_parts']}") + print(f" Extended parts: {stats['extended_parts']}") + print(f" In stock: {stats['in_stock']}") + print(f" Database: {stats['db_path']}") + + if stats["total_parts"] > 0: + print("\nSearching for '10k resistor'...") + results = manager.search_parts(query="10k resistor", limit=5) + for part in results: + print( + f" {part['lcsc']}: {part['mfr_part']} - {part['description']} ({part['library_type']})" + ) diff --git a/python/commands/jlcsearch.py b/python/commands/jlcsearch.py index 1ca6ad2..1210f61 100644 --- a/python/commands/jlcsearch.py +++ b/python/commands/jlcsearch.py @@ -1,240 +1,240 @@ -""" -JLCSearch API client (public, no authentication required) - -Alternative to official JLCPCB API using the community-maintained -jlcsearch service at https://jlcsearch.tscircuit.com/ -""" - -import logging -import time -from typing import Any, Callable, Dict, List, Optional, Union - -import requests - -logger = logging.getLogger("kicad_interface") - - -class JLCSearchClient: - """ - Client for JLCSearch public API (tscircuit) - - Provides access to JLCPCB parts database without authentication - via the community-maintained jlcsearch service. - """ - - BASE_URL = "https://jlcsearch.tscircuit.com" - - def __init__(self) -> None: - """Initialize JLCSearch API client""" - pass - - def search_components( - self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict - ) -> List[Dict]: - """ - Search components in JLCSearch database - - Args: - category: Component category (e.g., "resistors", "capacitors", "components") - limit: Maximum number of results - offset: Offset for pagination - **filters: Additional filters (e.g., package="0603", resistance=1000) - - Returns: - List of component dicts - """ - url = f"{self.BASE_URL}/{category}/list.json" - - params = {"limit": limit, "offset": offset, **filters} - - try: - response = requests.get(url, params=params, timeout=30) - response.raise_for_status() - data = response.json() - - # The response has the category name as key - # e.g., {"resistors": [...]} or {"components": [...]} - for key, value in data.items(): - if isinstance(value, list): - return value - - return [] - - except requests.exceptions.RequestException as e: - logger.error(f"Failed to search JLCSearch: {e}") - raise Exception(f"JLCSearch API request failed: {e}") - - def search_resistors( - self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100 - ) -> List[Dict]: - """ - Search for resistors - - Args: - resistance: Resistance value in ohms - package: Package type (e.g., "0603", "0805") - limit: Maximum results - - Returns: - List of resistor dicts with fields: - - lcsc: LCSC number (integer) - - mfr: Manufacturer part number - - package: Package size - - is_basic: True if basic library part - - resistance: Resistance in ohms - - tolerance_fraction: Tolerance (0.01 = 1%) - - power_watts: Power rating in mW - - stock: Available stock - - price1: Price per unit - """ - filters: Dict[str, Any] = {} - if resistance is not None: - filters["resistance"] = resistance - if package: - filters["package"] = package - - return self.search_components("resistors", limit=limit, **filters) - - def search_capacitors( - self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100 - ) -> List[Dict]: - """ - Search for capacitors - - Args: - capacitance: Capacitance value in farads - package: Package type - limit: Maximum results - - Returns: - List of capacitor dicts - """ - filters: Dict[str, Any] = {} - if capacitance is not None: - filters["capacitance"] = capacitance - if package: - filters["package"] = package - - return self.search_components("capacitors", limit=limit, **filters) - - def get_part_by_lcsc(self, lcsc_number: int) -> Optional[Dict]: - """ - Get part details by LCSC number - - Args: - lcsc_number: LCSC number (integer, without 'C' prefix) - - Returns: - Part dict or None if not found - """ - # Search across all components filtering by LCSC - # Note: jlcsearch doesn't have a dedicated single-part endpoint - # so we search and filter - try: - results = self.search_components("components", limit=1, lcsc=lcsc_number) - return results[0] if results else None - except Exception as e: - logger.error(f"Failed to get part C{lcsc_number}: {e}") - return None - - def download_all_components( - self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100 - ) -> List[Dict]: - """ - Download all components from jlcsearch database - - Note: tscircuit API has a hard-coded 100 result limit per request. - Full catalog download requires ~25,000 paginated requests (~40-60 minutes). - - Args: - callback: Optional progress callback function(parts_count, status_msg) - batch_size: Number of parts per batch (max 100 due to API limit) - - Returns: - List of all parts - """ - all_parts = [] - offset = 0 - - logger.info("Starting full jlcsearch parts database download...") - - while True: - try: - batch = self.search_components("components", limit=batch_size, offset=offset) - - # Stop if no results returned (end of catalog) - if not batch or len(batch) == 0: - break - - all_parts.extend(batch) - offset += len(batch) - - if callback: - callback(len(all_parts), f"Downloaded {len(all_parts)} parts...") - else: - logger.info(f"Downloaded {len(all_parts)} parts so far...") - - # Continue pagination - API returns exactly 100 results per page until exhausted - # Only stop when we get 0 results (handled above) - - # Rate limiting - be nice to the API - time.sleep(0.1) - - except Exception as e: - logger.error(f"Error downloading parts at offset {offset}: {e}") - if len(all_parts) > 0: - logger.warning(f"Partial download available: {len(all_parts)} parts") - return all_parts - else: - raise - - logger.info(f"Download complete: {len(all_parts)} parts retrieved") - return all_parts - - -def test_jlcsearch_connection() -> bool: - """ - Test JLCSearch API connection - - Returns: - True if connection successful, False otherwise - """ - try: - client = JLCSearchClient() - # Test by searching for 1k resistors - results = client.search_resistors(resistance=1000, limit=5) - logger.info(f"JLCSearch API connection test successful - found {len(results)} resistors") - return True - except Exception as e: - logger.error(f"JLCSearch API connection test failed: {e}") - return False - - -if __name__ == "__main__": - # Test the JLCSearch client - logging.basicConfig(level=logging.INFO) - - print("Testing JLCSearch API connection...") - if test_jlcsearch_connection(): - print("✓ Connection successful!") - - client = JLCSearchClient() - - print("\nSearching for 1k 0603 resistors...") - resistors = client.search_resistors(resistance=1000, package="0603", limit=5) - print(f"✓ Found {len(resistors)} resistors") - - if resistors: - print(f"\nExample resistor:") - r = resistors[0] - print(f" LCSC: C{r.get('lcsc')}") - print(f" MFR: {r.get('mfr')}") - print(f" Package: {r.get('package')}") - print(f" Resistance: {r.get('resistance')}Ω") - print(f" Tolerance: {r.get('tolerance_fraction', 0) * 100}%") - print(f" Power: {r.get('power_watts')}mW") - print(f" Stock: {r.get('stock')}") - print(f" Price: ${r.get('price1')}") - print(f" Basic Library: {'Yes' if r.get('is_basic') else 'No'}") - else: - print("✗ Connection failed") +""" +JLCSearch API client (public, no authentication required) + +Alternative to official JLCPCB API using the community-maintained +jlcsearch service at https://jlcsearch.tscircuit.com/ +""" + +import logging +import time +from typing import Any, Callable, Dict, List, Optional, Union + +import requests + +logger = logging.getLogger("kicad_interface") + + +class JLCSearchClient: + """ + Client for JLCSearch public API (tscircuit) + + Provides access to JLCPCB parts database without authentication + via the community-maintained jlcsearch service. + """ + + BASE_URL = "https://jlcsearch.tscircuit.com" + + def __init__(self) -> None: + """Initialize JLCSearch API client""" + pass + + def search_components( + self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict + ) -> List[Dict]: + """ + Search components in JLCSearch database + + Args: + category: Component category (e.g., "resistors", "capacitors", "components") + limit: Maximum number of results + offset: Offset for pagination + **filters: Additional filters (e.g., package="0603", resistance=1000) + + Returns: + List of component dicts + """ + url = f"{self.BASE_URL}/{category}/list.json" + + params = {"limit": limit, "offset": offset, **filters} + + try: + response = requests.get(url, params=params, timeout=30) + response.raise_for_status() + data = response.json() + + # The response has the category name as key + # e.g., {"resistors": [...]} or {"components": [...]} + for key, value in data.items(): + if isinstance(value, list): + return value + + return [] + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to search JLCSearch: {e}") + raise Exception(f"JLCSearch API request failed: {e}") + + def search_resistors( + self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100 + ) -> List[Dict]: + """ + Search for resistors + + Args: + resistance: Resistance value in ohms + package: Package type (e.g., "0603", "0805") + limit: Maximum results + + Returns: + List of resistor dicts with fields: + - lcsc: LCSC number (integer) + - mfr: Manufacturer part number + - package: Package size + - is_basic: True if basic library part + - resistance: Resistance in ohms + - tolerance_fraction: Tolerance (0.01 = 1%) + - power_watts: Power rating in mW + - stock: Available stock + - price1: Price per unit + """ + filters: Dict[str, Any] = {} + if resistance is not None: + filters["resistance"] = resistance + if package: + filters["package"] = package + + return self.search_components("resistors", limit=limit, **filters) + + def search_capacitors( + self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100 + ) -> List[Dict]: + """ + Search for capacitors + + Args: + capacitance: Capacitance value in farads + package: Package type + limit: Maximum results + + Returns: + List of capacitor dicts + """ + filters: Dict[str, Any] = {} + if capacitance is not None: + filters["capacitance"] = capacitance + if package: + filters["package"] = package + + return self.search_components("capacitors", limit=limit, **filters) + + def get_part_by_lcsc(self, lcsc_number: int) -> Optional[Dict]: + """ + Get part details by LCSC number + + Args: + lcsc_number: LCSC number (integer, without 'C' prefix) + + Returns: + Part dict or None if not found + """ + # Search across all components filtering by LCSC + # Note: jlcsearch doesn't have a dedicated single-part endpoint + # so we search and filter + try: + results = self.search_components("components", limit=1, lcsc=lcsc_number) + return results[0] if results else None + except Exception as e: + logger.error(f"Failed to get part C{lcsc_number}: {e}") + return None + + def download_all_components( + self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100 + ) -> List[Dict]: + """ + Download all components from jlcsearch database + + Note: tscircuit API has a hard-coded 100 result limit per request. + Full catalog download requires ~25,000 paginated requests (~40-60 minutes). + + Args: + callback: Optional progress callback function(parts_count, status_msg) + batch_size: Number of parts per batch (max 100 due to API limit) + + Returns: + List of all parts + """ + all_parts = [] + offset = 0 + + logger.info("Starting full jlcsearch parts database download...") + + while True: + try: + batch = self.search_components("components", limit=batch_size, offset=offset) + + # Stop if no results returned (end of catalog) + if not batch or len(batch) == 0: + break + + all_parts.extend(batch) + offset += len(batch) + + if callback: + callback(len(all_parts), f"Downloaded {len(all_parts)} parts...") + else: + logger.info(f"Downloaded {len(all_parts)} parts so far...") + + # Continue pagination - API returns exactly 100 results per page until exhausted + # Only stop when we get 0 results (handled above) + + # Rate limiting - be nice to the API + time.sleep(0.1) + + except Exception as e: + logger.error(f"Error downloading parts at offset {offset}: {e}") + if len(all_parts) > 0: + logger.warning(f"Partial download available: {len(all_parts)} parts") + return all_parts + else: + raise + + logger.info(f"Download complete: {len(all_parts)} parts retrieved") + return all_parts + + +def test_jlcsearch_connection() -> bool: + """ + Test JLCSearch API connection + + Returns: + True if connection successful, False otherwise + """ + try: + client = JLCSearchClient() + # Test by searching for 1k resistors + results = client.search_resistors(resistance=1000, limit=5) + logger.info(f"JLCSearch API connection test successful - found {len(results)} resistors") + return True + except Exception as e: + logger.error(f"JLCSearch API connection test failed: {e}") + return False + + +if __name__ == "__main__": + # Test the JLCSearch client + logging.basicConfig(level=logging.INFO) + + print("Testing JLCSearch API connection...") + if test_jlcsearch_connection(): + print("✓ Connection successful!") + + client = JLCSearchClient() + + print("\nSearching for 1k 0603 resistors...") + resistors = client.search_resistors(resistance=1000, package="0603", limit=5) + print(f"✓ Found {len(resistors)} resistors") + + if resistors: + print(f"\nExample resistor:") + r = resistors[0] + print(f" LCSC: C{r.get('lcsc')}") + print(f" MFR: {r.get('mfr')}") + print(f" Package: {r.get('package')}") + print(f" Resistance: {r.get('resistance')}Ω") + print(f" Tolerance: {r.get('tolerance_fraction', 0) * 100}%") + print(f" Power: {r.get('power_watts')}mW") + print(f" Stock: {r.get('stock')}") + print(f" Price: ${r.get('price1')}") + print(f" Basic Library: {'Yes' if r.get('is_basic') else 'No'}") + else: + print("✗ Connection failed") diff --git a/python/commands/library.py b/python/commands/library.py index f38c496..353d3ff 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -1,555 +1,555 @@ -""" -Library management for KiCAD footprints - -Handles parsing fp-lib-table files, discovering footprints, -and providing search functionality for component placement. -""" - -import glob -import logging -import os -import re -from pathlib import Path -from typing import Dict, List, Optional, Tuple - -logger = logging.getLogger("kicad_interface") - - -class LibraryManager: - """ - Manages KiCAD footprint libraries - - Parses fp-lib-table files (both global and project-specific), - indexes available footprints, and provides search functionality. - """ - - def __init__(self, project_path: Optional[Path] = None): - """ - Initialize library manager - - Args: - project_path: Optional path to project directory for project-specific libraries - """ - self.project_path = project_path - self.libraries: Dict[str, str] = {} # nickname -> path mapping - self.footprint_cache: Dict[str, List[str]] = {} # library -> [footprint names] - self._load_libraries() - - def _load_libraries(self) -> None: - """Load libraries from fp-lib-table files""" - # Load global libraries - global_table = self._get_global_fp_lib_table() - if global_table and global_table.exists(): - logger.info(f"Loading global fp-lib-table from: {global_table}") - self._parse_fp_lib_table(global_table) - else: - logger.warning(f"Global fp-lib-table not found at: {global_table}") - - # Load project-specific libraries if project path provided - if self.project_path: - project_table = self.project_path / "fp-lib-table" - if project_table.exists(): - logger.info(f"Loading project fp-lib-table from: {project_table}") - self._parse_fp_lib_table(project_table) - - logger.info(f"Loaded {len(self.libraries)} footprint libraries") - - def _get_global_fp_lib_table(self) -> Optional[Path]: - """Get path to global fp-lib-table file""" - # Try different possible locations - kicad_config_paths = [ - Path.home() / ".config" / "kicad" / "10.0" / "fp-lib-table", - Path.home() / ".config" / "kicad" / "9.0" / "fp-lib-table", - Path.home() / ".config" / "kicad" / "8.0" / "fp-lib-table", - Path.home() / ".config" / "kicad" / "fp-lib-table", - # Windows paths - Path.home() / "AppData" / "Roaming" / "kicad" / "10.0" / "fp-lib-table", - Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "fp-lib-table", - Path.home() / "AppData" / "Roaming" / "kicad" / "8.0" / "fp-lib-table", - # macOS paths - Path.home() / "Library" / "Preferences" / "kicad" / "10.0" / "fp-lib-table", - Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "fp-lib-table", - Path.home() / "Library" / "Preferences" / "kicad" / "8.0" / "fp-lib-table", - ] - - for path in kicad_config_paths: - if path.exists(): - return path - - return None - - def _parse_fp_lib_table(self, table_path: Path) -> None: - """ - Parse fp-lib-table file - - Format is S-expression (Lisp-like): - (fp_lib_table - (lib (name "Library_Name")(type KiCad)(uri "${KICAD9_FOOTPRINT_DIR}/Library.pretty")(options "")(descr "Description")) - ) - """ - try: - with open(table_path, "r") as f: - content = f.read() - - # Simple regex-based parser for lib entries - # Pattern: (lib (name "NAME")(type TYPE)(uri "URI")...) - lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+"?([^")\s]+)"?\)\s*\(uri\s+"?([^")\s]+)"?' - - for match in re.finditer(lib_pattern, content, re.IGNORECASE): - nickname = match.group(1) - lib_type = match.group(2) - uri = match.group(3) - - if lib_type.lower() == "table": - table_uri = uri - if os.path.isabs(table_uri) and os.path.isfile(table_uri): - logger.info(f" Following Table reference: {nickname} -> {table_uri}") - self._parse_fp_lib_table(Path(table_uri)) - else: - logger.warning(f" Could not resolve Table URI: {table_uri}") - continue - - # Resolve environment variables in URI - resolved_uri = self._resolve_uri(uri) - - if resolved_uri: - self.libraries[nickname] = resolved_uri - logger.debug(f" Found library: {nickname} -> {resolved_uri}") - else: - logger.warning(f" Could not resolve URI for library {nickname}: {uri}") - - except Exception as e: - logger.error(f"Error parsing fp-lib-table at {table_path}: {e}") - - def _resolve_uri(self, uri: str) -> Optional[str]: - """ - Resolve environment variables and paths in library URI - - Handles: - - ${KICAD9_FOOTPRINT_DIR} -> /usr/share/kicad/footprints - - ${KICAD8_FOOTPRINT_DIR} -> /usr/share/kicad/footprints - - ${KIPRJMOD} -> project directory - - Relative paths - - Absolute paths - """ - # Replace environment variables - resolved = uri - - # Common KiCAD environment variables - env_vars = { - "KICAD10_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), - "KICAD9_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), - "KICAD8_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), - "KICAD_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), - "KISYSMOD": self._find_kicad_footprint_dir(), - "KICAD10_3RD_PARTY": self._find_kicad_3rdparty_dir(), - "KICAD9_3RD_PARTY": self._find_kicad_3rdparty_dir(), - "KICAD8_3RD_PARTY": self._find_kicad_3rdparty_dir(), - } - - # Project directory - if self.project_path: - env_vars["KIPRJMOD"] = str(self.project_path) - - # Replace environment variables - for var, value in env_vars.items(): - if value: - resolved = resolved.replace(f"${{{var}}}", value) - resolved = resolved.replace(f"${var}", value) - - # Expand ~ to home directory - resolved = os.path.expanduser(resolved) - - # Convert to absolute path - path = Path(resolved) - - # Check if path exists - if path.exists(): - return str(path) - else: - logger.debug(f" Path does not exist: {path}") - return None - - def _find_kicad_footprint_dir(self) -> Optional[str]: - """Find KiCAD footprint directory""" - # Try common locations - possible_paths = [ - "/usr/share/kicad/footprints", - "/usr/local/share/kicad/footprints", - "C:/Program Files/KiCad/9.0/share/kicad/footprints", - "C:/Program Files/KiCad/8.0/share/kicad/footprints", - "/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints", - ] - - # Also check environment variable - if "KICAD9_FOOTPRINT_DIR" in os.environ: - possible_paths.insert(0, os.environ["KICAD9_FOOTPRINT_DIR"]) - if "KICAD8_FOOTPRINT_DIR" in os.environ: - possible_paths.insert(0, os.environ["KICAD8_FOOTPRINT_DIR"]) - - for path in possible_paths: - if os.path.isdir(path): - return path - - return None - - def _find_kicad_3rdparty_dir(self) -> Optional[str]: - """ - Find KiCAD 3rd party libraries directory. - - Resolution order: - 1. Shell environment variable KICAD9_3RD_PARTY - 2. User settings in kicad_common.json - 3. Platform-specific defaults based on detected KiCad version - """ - import json - - # 1. Check shell environment variable first - if "KICAD9_3RD_PARTY" in os.environ: - path = os.environ["KICAD9_3RD_PARTY"] - if os.path.isdir(path): - return path - - # 2. Check kicad_common.json for user-defined variables - kicad_common_paths = [ - Path.home() - / "Library" - / "Preferences" - / "kicad" - / "9.0" - / "kicad_common.json", # macOS - Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux - Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "kicad_common.json", # Windows - ] - - for config_path in kicad_common_paths: - if config_path.exists(): - try: - with open(config_path, "r") as f: - config = json.load(f) - env_vars = config.get("environment", {}).get("vars", {}) - if env_vars and "KICAD9_3RD_PARTY" in env_vars: - path = env_vars["KICAD9_3RD_PARTY"] - if os.path.isdir(path): - return path - except (json.JSONDecodeError, KeyError, TypeError): - pass - - # Derive version from config path location - version = config_path.parent.name # e.g., "9.0" - break - else: - version = "9.0" # Default - - # 3. Use platform-specific defaults - possible_paths = [ - # macOS - Documents/KiCad/{version}/3rdparty - Path.home() / "Documents" / "KiCad" / version / "3rdparty", - # Linux - ~/.local/share/kicad/{version}/3rdparty - Path.home() / ".local" / "share" / "kicad" / version / "3rdparty", - # Windows - Documents/KiCad/{version}/3rdparty - Path.home() / "Documents" / "KiCad" / version / "3rdparty", - ] - - for candidate in possible_paths: - if candidate.exists(): - logger.info(f"Found KiCad 3rd party directory: {candidate}") - return str(candidate) - - logger.warning("Could not find KiCad 3rd party directory") - return None - - def list_libraries(self) -> List[str]: - """Get list of available library nicknames""" - return list(self.libraries.keys()) - - def get_library_path(self, nickname: str) -> Optional[str]: - """Get filesystem path for a library nickname""" - return self.libraries.get(nickname) - - def list_footprints(self, library_nickname: str) -> List[str]: - """ - List all footprints in a library - - Args: - library_nickname: Library name (e.g., "Resistor_SMD") - - Returns: - List of footprint names (without .kicad_mod extension) - """ - # Check cache first - if library_nickname in self.footprint_cache: - return self.footprint_cache[library_nickname] - - library_path = self.libraries.get(library_nickname) - if not library_path: - logger.warning(f"Library not found: {library_nickname}") - return [] - - try: - footprints = [] - lib_dir = Path(library_path) - - # List all .kicad_mod files - for fp_file in lib_dir.glob("*.kicad_mod"): - # Remove .kicad_mod extension - footprint_name = fp_file.stem - footprints.append(footprint_name) - - # Cache the results - self.footprint_cache[library_nickname] = footprints - logger.debug(f"Found {len(footprints)} footprints in {library_nickname}") - - return footprints - - except Exception as e: - logger.error(f"Error listing footprints in {library_nickname}: {e}") - return [] - - def find_footprint(self, footprint_spec: str) -> Optional[Tuple[str, str]]: - """ - Find a footprint by specification - - Supports multiple formats: - - "Library:Footprint" (e.g., "Resistor_SMD:R_0603_1608Metric") - - "Footprint" (searches all libraries) - - Args: - footprint_spec: Footprint specification - - Returns: - Tuple of (library_path, footprint_name) or None if not found - """ - # Parse specification - if ":" in footprint_spec: - # Format: Library:Footprint - library_nickname, footprint_name = footprint_spec.split(":", 1) - library_path = self.libraries.get(library_nickname) - - if not library_path: - logger.warning(f"Library not found: {library_nickname}") - return None - - # Check if footprint exists - fp_file = Path(library_path) / f"{footprint_name}.kicad_mod" - if fp_file.exists(): - return (library_path, footprint_name) - else: - logger.warning(f"Footprint not found: {footprint_spec}") - return None - else: - # Format: Footprint (search all libraries) - footprint_name = footprint_spec - - # Search in all libraries - for library_nickname, library_path in self.libraries.items(): - fp_file = Path(library_path) / f"{footprint_name}.kicad_mod" - if fp_file.exists(): - logger.info(f"Found footprint {footprint_name} in library {library_nickname}") - return (library_path, footprint_name) - - logger.warning(f"Footprint not found in any library: {footprint_name}") - return None - - def search_footprints(self, pattern: str, limit: int = 20) -> List[Dict[str, str]]: - """ - Search for footprints matching a pattern - - Args: - pattern: Search pattern (supports wildcards *, case-insensitive) - limit: Maximum number of results to return - - Returns: - List of dicts with 'library', 'footprint', and 'full_name' keys - """ - results = [] - pattern_lower = pattern.lower() - - # Convert wildcards to regex - regex_pattern = pattern_lower.replace("*", ".*") - regex = re.compile(regex_pattern) - - for library_nickname in self.libraries.keys(): - footprints = self.list_footprints(library_nickname) - - for footprint in footprints: - if regex.search(footprint.lower()): - results.append( - { - "library": library_nickname, - "footprint": footprint, - "full_name": f"{library_nickname}:{footprint}", - } - ) - - if len(results) >= limit: - return results - - return results - - def get_footprint_info( - self, library_nickname: str, footprint_name: str - ) -> Optional[Dict[str, str]]: - """ - Get information about a specific footprint - - Args: - library_nickname: Library name - footprint_name: Footprint name - - Returns: - Dict with footprint information or None if not found - """ - library_path = self.libraries.get(library_nickname) - if not library_path: - return None - - fp_file = Path(library_path) / f"{footprint_name}.kicad_mod" - if not fp_file.exists(): - return None - - return { - "library": library_nickname, - "footprint": footprint_name, - "full_name": f"{library_nickname}:{footprint_name}", - "path": str(fp_file), - "library_path": library_path, - } - - -class LibraryCommands: - """Command handlers for library operations""" - - def __init__(self, library_manager: Optional[LibraryManager] = None): - """Initialize with optional library manager""" - self.library_manager = library_manager or LibraryManager() - - def list_libraries(self, params: Dict) -> Dict: - """List all available footprint libraries""" - try: - libraries = self.library_manager.list_libraries() - return {"success": True, "libraries": libraries, "count": len(libraries)} - except Exception as e: - logger.error(f"Error listing libraries: {e}") - return { - "success": False, - "message": "Failed to list libraries", - "errorDetails": str(e), - } - - def search_footprints(self, params: Dict) -> Dict: - """Search for footprints by pattern""" - try: - # Support both 'pattern' and 'search_term' parameter names - pattern = params.get("pattern") or params.get("search_term", "*") - limit = params.get("limit", 20) - library_filter = params.get("library") - - results = self.library_manager.search_footprints( - pattern, limit * 10 if library_filter else limit - ) - - # Filter by library if specified - if library_filter: - results = [ - r for r in results if r.get("library", "").lower() == library_filter.lower() - ] - results = results[:limit] - - return { - "success": True, - "footprints": results, - "count": len(results), - "pattern": pattern, - } - except Exception as e: - logger.error(f"Error searching footprints: {e}") - return { - "success": False, - "message": "Failed to search footprints", - "errorDetails": str(e), - } - - def list_library_footprints(self, params: Dict) -> Dict: - """List all footprints in a specific library""" - try: - library = params.get("library") or params.get("library_name") - if not library: - return {"success": False, "message": "Missing library parameter"} - - footprints = self.library_manager.list_footprints(library) - - return { - "success": True, - "library": library, - "footprints": footprints, - "count": len(footprints), - } - except Exception as e: - logger.error(f"Error listing library footprints: {e}") - return { - "success": False, - "message": "Failed to list library footprints", - "errorDetails": str(e), - } - - def get_footprint_info(self, params: Dict) -> Dict: - """Get information about a specific footprint""" - try: - footprint_spec = params.get("footprint_name") - if not footprint_spec: - return {"success": False, "message": "Missing footprint parameter"} - - # Try to find the footprint - result = self.library_manager.find_footprint(footprint_spec) - - if result: - library_path, footprint_name = result - # Extract library nickname from path - library_nickname = None - for nick, path in self.library_manager.libraries.items(): - if path == library_path: - library_nickname = nick - break - - # Minimal info — always returned even if the parser fails - info: Dict = { - "library": library_nickname, - "name": footprint_name, - "full_name": f"{library_nickname}:{footprint_name}", - "library_path": library_path, - } - - # Attempt to enrich with parsed .kicad_mod data - try: - from pathlib import Path as _Path - - from parsers.kicad_mod_parser import parse_kicad_mod - - mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod") - parsed = parse_kicad_mod(mod_file) - if parsed: - # Merge parser output into info; keep our resolved library context - info.update(parsed) - info["name"] = footprint_name # entry name wins over in-file name - info["library"] = library_nickname - info["full_name"] = f"{library_nickname}:{footprint_name}" - info["library_path"] = library_path - else: - logger.warning( - f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info" - ) - except Exception as parse_err: - logger.warning( - f"get_footprint_info: parser error ({parse_err}), using minimal info" - ) - - return {"success": True, "info": info} - - except Exception as e: - logger.error(f"Error getting footprint info: {e}") - return { - "success": False, - "message": "Failed to get footprint info", - "errorDetails": str(e), - } +""" +Library management for KiCAD footprints + +Handles parsing fp-lib-table files, discovering footprints, +and providing search functionality for component placement. +""" + +import glob +import logging +import os +import re +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger("kicad_interface") + + +class LibraryManager: + """ + Manages KiCAD footprint libraries + + Parses fp-lib-table files (both global and project-specific), + indexes available footprints, and provides search functionality. + """ + + def __init__(self, project_path: Optional[Path] = None): + """ + Initialize library manager + + Args: + project_path: Optional path to project directory for project-specific libraries + """ + self.project_path = project_path + self.libraries: Dict[str, str] = {} # nickname -> path mapping + self.footprint_cache: Dict[str, List[str]] = {} # library -> [footprint names] + self._load_libraries() + + def _load_libraries(self) -> None: + """Load libraries from fp-lib-table files""" + # Load global libraries + global_table = self._get_global_fp_lib_table() + if global_table and global_table.exists(): + logger.info(f"Loading global fp-lib-table from: {global_table}") + self._parse_fp_lib_table(global_table) + else: + logger.warning(f"Global fp-lib-table not found at: {global_table}") + + # Load project-specific libraries if project path provided + if self.project_path: + project_table = self.project_path / "fp-lib-table" + if project_table.exists(): + logger.info(f"Loading project fp-lib-table from: {project_table}") + self._parse_fp_lib_table(project_table) + + logger.info(f"Loaded {len(self.libraries)} footprint libraries") + + def _get_global_fp_lib_table(self) -> Optional[Path]: + """Get path to global fp-lib-table file""" + # Try different possible locations + kicad_config_paths = [ + Path.home() / ".config" / "kicad" / "10.0" / "fp-lib-table", + Path.home() / ".config" / "kicad" / "9.0" / "fp-lib-table", + Path.home() / ".config" / "kicad" / "8.0" / "fp-lib-table", + Path.home() / ".config" / "kicad" / "fp-lib-table", + # Windows paths + Path.home() / "AppData" / "Roaming" / "kicad" / "10.0" / "fp-lib-table", + Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "fp-lib-table", + Path.home() / "AppData" / "Roaming" / "kicad" / "8.0" / "fp-lib-table", + # macOS paths + Path.home() / "Library" / "Preferences" / "kicad" / "10.0" / "fp-lib-table", + Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "fp-lib-table", + Path.home() / "Library" / "Preferences" / "kicad" / "8.0" / "fp-lib-table", + ] + + for path in kicad_config_paths: + if path.exists(): + return path + + return None + + def _parse_fp_lib_table(self, table_path: Path) -> None: + """ + Parse fp-lib-table file + + Format is S-expression (Lisp-like): + (fp_lib_table + (lib (name "Library_Name")(type KiCad)(uri "${KICAD9_FOOTPRINT_DIR}/Library.pretty")(options "")(descr "Description")) + ) + """ + try: + with open(table_path, "r") as f: + content = f.read() + + # Simple regex-based parser for lib entries + # Pattern: (lib (name "NAME")(type TYPE)(uri "URI")...) + lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+"?([^")\s]+)"?\)\s*\(uri\s+"?([^")\s]+)"?' + + for match in re.finditer(lib_pattern, content, re.IGNORECASE): + nickname = match.group(1) + lib_type = match.group(2) + uri = match.group(3) + + if lib_type.lower() == "table": + table_uri = uri + if os.path.isabs(table_uri) and os.path.isfile(table_uri): + logger.info(f" Following Table reference: {nickname} -> {table_uri}") + self._parse_fp_lib_table(Path(table_uri)) + else: + logger.warning(f" Could not resolve Table URI: {table_uri}") + continue + + # Resolve environment variables in URI + resolved_uri = self._resolve_uri(uri) + + if resolved_uri: + self.libraries[nickname] = resolved_uri + logger.debug(f" Found library: {nickname} -> {resolved_uri}") + else: + logger.warning(f" Could not resolve URI for library {nickname}: {uri}") + + except Exception as e: + logger.error(f"Error parsing fp-lib-table at {table_path}: {e}") + + def _resolve_uri(self, uri: str) -> Optional[str]: + """ + Resolve environment variables and paths in library URI + + Handles: + - ${KICAD9_FOOTPRINT_DIR} -> /usr/share/kicad/footprints + - ${KICAD8_FOOTPRINT_DIR} -> /usr/share/kicad/footprints + - ${KIPRJMOD} -> project directory + - Relative paths + - Absolute paths + """ + # Replace environment variables + resolved = uri + + # Common KiCAD environment variables + env_vars = { + "KICAD10_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), + "KICAD9_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), + "KICAD8_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), + "KICAD_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), + "KISYSMOD": self._find_kicad_footprint_dir(), + "KICAD10_3RD_PARTY": self._find_kicad_3rdparty_dir(), + "KICAD9_3RD_PARTY": self._find_kicad_3rdparty_dir(), + "KICAD8_3RD_PARTY": self._find_kicad_3rdparty_dir(), + } + + # Project directory + if self.project_path: + env_vars["KIPRJMOD"] = str(self.project_path) + + # Replace environment variables + for var, value in env_vars.items(): + if value: + resolved = resolved.replace(f"${{{var}}}", value) + resolved = resolved.replace(f"${var}", value) + + # Expand ~ to home directory + resolved = os.path.expanduser(resolved) + + # Convert to absolute path + path = Path(resolved) + + # Check if path exists + if path.exists(): + return str(path) + else: + logger.debug(f" Path does not exist: {path}") + return None + + def _find_kicad_footprint_dir(self) -> Optional[str]: + """Find KiCAD footprint directory""" + # Try common locations + possible_paths = [ + "/usr/share/kicad/footprints", + "/usr/local/share/kicad/footprints", + "C:/Program Files/KiCad/9.0/share/kicad/footprints", + "C:/Program Files/KiCad/8.0/share/kicad/footprints", + "/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints", + ] + + # Also check environment variable + if "KICAD9_FOOTPRINT_DIR" in os.environ: + possible_paths.insert(0, os.environ["KICAD9_FOOTPRINT_DIR"]) + if "KICAD8_FOOTPRINT_DIR" in os.environ: + possible_paths.insert(0, os.environ["KICAD8_FOOTPRINT_DIR"]) + + for path in possible_paths: + if os.path.isdir(path): + return path + + return None + + def _find_kicad_3rdparty_dir(self) -> Optional[str]: + """ + Find KiCAD 3rd party libraries directory. + + Resolution order: + 1. Shell environment variable KICAD9_3RD_PARTY + 2. User settings in kicad_common.json + 3. Platform-specific defaults based on detected KiCad version + """ + import json + + # 1. Check shell environment variable first + if "KICAD9_3RD_PARTY" in os.environ: + path = os.environ["KICAD9_3RD_PARTY"] + if os.path.isdir(path): + return path + + # 2. Check kicad_common.json for user-defined variables + kicad_common_paths = [ + Path.home() + / "Library" + / "Preferences" + / "kicad" + / "9.0" + / "kicad_common.json", # macOS + Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux + Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "kicad_common.json", # Windows + ] + + for config_path in kicad_common_paths: + if config_path.exists(): + try: + with open(config_path, "r") as f: + config = json.load(f) + env_vars = config.get("environment", {}).get("vars", {}) + if env_vars and "KICAD9_3RD_PARTY" in env_vars: + path = env_vars["KICAD9_3RD_PARTY"] + if os.path.isdir(path): + return path + except (json.JSONDecodeError, KeyError, TypeError): + pass + + # Derive version from config path location + version = config_path.parent.name # e.g., "9.0" + break + else: + version = "9.0" # Default + + # 3. Use platform-specific defaults + possible_paths = [ + # macOS - Documents/KiCad/{version}/3rdparty + Path.home() / "Documents" / "KiCad" / version / "3rdparty", + # Linux - ~/.local/share/kicad/{version}/3rdparty + Path.home() / ".local" / "share" / "kicad" / version / "3rdparty", + # Windows - Documents/KiCad/{version}/3rdparty + Path.home() / "Documents" / "KiCad" / version / "3rdparty", + ] + + for candidate in possible_paths: + if candidate.exists(): + logger.info(f"Found KiCad 3rd party directory: {candidate}") + return str(candidate) + + logger.warning("Could not find KiCad 3rd party directory") + return None + + def list_libraries(self) -> List[str]: + """Get list of available library nicknames""" + return list(self.libraries.keys()) + + def get_library_path(self, nickname: str) -> Optional[str]: + """Get filesystem path for a library nickname""" + return self.libraries.get(nickname) + + def list_footprints(self, library_nickname: str) -> List[str]: + """ + List all footprints in a library + + Args: + library_nickname: Library name (e.g., "Resistor_SMD") + + Returns: + List of footprint names (without .kicad_mod extension) + """ + # Check cache first + if library_nickname in self.footprint_cache: + return self.footprint_cache[library_nickname] + + library_path = self.libraries.get(library_nickname) + if not library_path: + logger.warning(f"Library not found: {library_nickname}") + return [] + + try: + footprints = [] + lib_dir = Path(library_path) + + # List all .kicad_mod files + for fp_file in lib_dir.glob("*.kicad_mod"): + # Remove .kicad_mod extension + footprint_name = fp_file.stem + footprints.append(footprint_name) + + # Cache the results + self.footprint_cache[library_nickname] = footprints + logger.debug(f"Found {len(footprints)} footprints in {library_nickname}") + + return footprints + + except Exception as e: + logger.error(f"Error listing footprints in {library_nickname}: {e}") + return [] + + def find_footprint(self, footprint_spec: str) -> Optional[Tuple[str, str]]: + """ + Find a footprint by specification + + Supports multiple formats: + - "Library:Footprint" (e.g., "Resistor_SMD:R_0603_1608Metric") + - "Footprint" (searches all libraries) + + Args: + footprint_spec: Footprint specification + + Returns: + Tuple of (library_path, footprint_name) or None if not found + """ + # Parse specification + if ":" in footprint_spec: + # Format: Library:Footprint + library_nickname, footprint_name = footprint_spec.split(":", 1) + library_path = self.libraries.get(library_nickname) + + if not library_path: + logger.warning(f"Library not found: {library_nickname}") + return None + + # Check if footprint exists + fp_file = Path(library_path) / f"{footprint_name}.kicad_mod" + if fp_file.exists(): + return (library_path, footprint_name) + else: + logger.warning(f"Footprint not found: {footprint_spec}") + return None + else: + # Format: Footprint (search all libraries) + footprint_name = footprint_spec + + # Search in all libraries + for library_nickname, library_path in self.libraries.items(): + fp_file = Path(library_path) / f"{footprint_name}.kicad_mod" + if fp_file.exists(): + logger.info(f"Found footprint {footprint_name} in library {library_nickname}") + return (library_path, footprint_name) + + logger.warning(f"Footprint not found in any library: {footprint_name}") + return None + + def search_footprints(self, pattern: str, limit: int = 20) -> List[Dict[str, str]]: + """ + Search for footprints matching a pattern + + Args: + pattern: Search pattern (supports wildcards *, case-insensitive) + limit: Maximum number of results to return + + Returns: + List of dicts with 'library', 'footprint', and 'full_name' keys + """ + results = [] + pattern_lower = pattern.lower() + + # Convert wildcards to regex + regex_pattern = pattern_lower.replace("*", ".*") + regex = re.compile(regex_pattern) + + for library_nickname in self.libraries.keys(): + footprints = self.list_footprints(library_nickname) + + for footprint in footprints: + if regex.search(footprint.lower()): + results.append( + { + "library": library_nickname, + "footprint": footprint, + "full_name": f"{library_nickname}:{footprint}", + } + ) + + if len(results) >= limit: + return results + + return results + + def get_footprint_info( + self, library_nickname: str, footprint_name: str + ) -> Optional[Dict[str, str]]: + """ + Get information about a specific footprint + + Args: + library_nickname: Library name + footprint_name: Footprint name + + Returns: + Dict with footprint information or None if not found + """ + library_path = self.libraries.get(library_nickname) + if not library_path: + return None + + fp_file = Path(library_path) / f"{footprint_name}.kicad_mod" + if not fp_file.exists(): + return None + + return { + "library": library_nickname, + "footprint": footprint_name, + "full_name": f"{library_nickname}:{footprint_name}", + "path": str(fp_file), + "library_path": library_path, + } + + +class LibraryCommands: + """Command handlers for library operations""" + + def __init__(self, library_manager: Optional[LibraryManager] = None): + """Initialize with optional library manager""" + self.library_manager = library_manager or LibraryManager() + + def list_libraries(self, params: Dict) -> Dict: + """List all available footprint libraries""" + try: + libraries = self.library_manager.list_libraries() + return {"success": True, "libraries": libraries, "count": len(libraries)} + except Exception as e: + logger.error(f"Error listing libraries: {e}") + return { + "success": False, + "message": "Failed to list libraries", + "errorDetails": str(e), + } + + def search_footprints(self, params: Dict) -> Dict: + """Search for footprints by pattern""" + try: + # Support both 'pattern' and 'search_term' parameter names + pattern = params.get("pattern") or params.get("search_term", "*") + limit = params.get("limit", 20) + library_filter = params.get("library") + + results = self.library_manager.search_footprints( + pattern, limit * 10 if library_filter else limit + ) + + # Filter by library if specified + if library_filter: + results = [ + r for r in results if r.get("library", "").lower() == library_filter.lower() + ] + results = results[:limit] + + return { + "success": True, + "footprints": results, + "count": len(results), + "pattern": pattern, + } + except Exception as e: + logger.error(f"Error searching footprints: {e}") + return { + "success": False, + "message": "Failed to search footprints", + "errorDetails": str(e), + } + + def list_library_footprints(self, params: Dict) -> Dict: + """List all footprints in a specific library""" + try: + library = params.get("library") or params.get("library_name") + if not library: + return {"success": False, "message": "Missing library parameter"} + + footprints = self.library_manager.list_footprints(library) + + return { + "success": True, + "library": library, + "footprints": footprints, + "count": len(footprints), + } + except Exception as e: + logger.error(f"Error listing library footprints: {e}") + return { + "success": False, + "message": "Failed to list library footprints", + "errorDetails": str(e), + } + + def get_footprint_info(self, params: Dict) -> Dict: + """Get information about a specific footprint""" + try: + footprint_spec = params.get("footprint_name") + if not footprint_spec: + return {"success": False, "message": "Missing footprint parameter"} + + # Try to find the footprint + result = self.library_manager.find_footprint(footprint_spec) + + if result: + library_path, footprint_name = result + # Extract library nickname from path + library_nickname = None + for nick, path in self.library_manager.libraries.items(): + if path == library_path: + library_nickname = nick + break + + # Minimal info — always returned even if the parser fails + info: Dict = { + "library": library_nickname, + "name": footprint_name, + "full_name": f"{library_nickname}:{footprint_name}", + "library_path": library_path, + } + + # Attempt to enrich with parsed .kicad_mod data + try: + from pathlib import Path as _Path + + from parsers.kicad_mod_parser import parse_kicad_mod + + mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod") + parsed = parse_kicad_mod(mod_file) + if parsed: + # Merge parser output into info; keep our resolved library context + info.update(parsed) + info["name"] = footprint_name # entry name wins over in-file name + info["library"] = library_nickname + info["full_name"] = f"{library_nickname}:{footprint_name}" + info["library_path"] = library_path + else: + logger.warning( + f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info" + ) + except Exception as parse_err: + logger.warning( + f"get_footprint_info: parser error ({parse_err}), using minimal info" + ) + + return {"success": True, "info": info} + + except Exception as e: + logger.error(f"Error getting footprint info: {e}") + return { + "success": False, + "message": "Failed to get footprint info", + "errorDetails": str(e), + } diff --git a/python/commands/library_schematic.py b/python/commands/library_schematic.py index be96118..5afe501 100644 --- a/python/commands/library_schematic.py +++ b/python/commands/library_schematic.py @@ -1,130 +1,130 @@ -import glob -import logging - -# Symbol class might not be directly importable in the current version -import os -from typing import Any, Dict, List, Optional - -from skip import Schematic - -logger = logging.getLogger(__name__) - - -class LibraryManager: - """Manage symbol libraries""" - - @staticmethod - def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]: - """List all available symbol libraries""" - if search_paths is None: - # Default library paths based on common KiCAD installations - # This would need to be configured for the specific environment - search_paths = [ - "C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern - "/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern - "/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern - os.path.expanduser( - "~/Documents/KiCad/*/symbols/*.kicad_sym" - ), # User libraries pattern - ] - - libraries = [] - for path_pattern in search_paths: - try: - # Use glob to find all matching files - matching_libs = glob.glob(path_pattern, recursive=True) - libraries.extend(matching_libs) - except Exception as e: - logger.error(f"Error searching for libraries at {path_pattern}: {e}") - - # Extract library names from paths - library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries] - logger.info( - f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}" - ) - - # Return both full paths and library names - return {"paths": libraries, "names": library_names} - - @staticmethod - def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]: - """Get detailed information about a symbol""" - try: - logger.warning( - f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation." - ) - return {} - except Exception as e: - logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}") - return {} - - @staticmethod - def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]: - """Search for symbols matching criteria""" - try: - # This would typically involve: - # 1. Getting a list of all libraries using list_available_libraries - # 2. For each library, getting a list of all symbols - # 3. Filtering symbols based on the query - - # For now, this is a placeholder implementation - libraries = LibraryManager.list_available_libraries(search_paths) - - results = [] - logger.warning( - f"Searched for symbols matching '{query}'. This requires advanced implementation." - ) - return results - except Exception as e: - logger.error(f"Error searching for symbols matching '{query}': {e}") - return [] - - @staticmethod - def get_default_symbol_for_component_type( - component_type: str, search_paths: Optional[List[str]] = None - ) -> Dict[str, str]: - """Get a recommended default symbol for a given component type""" - # This method provides a simplified way to get a symbol for common component types - # It's useful when the user doesn't specify a particular library/symbol - - # Define common mappings from component type to library/symbol - common_mappings = { - "resistor": {"library": "Device", "symbol": "R"}, - "capacitor": {"library": "Device", "symbol": "C"}, - "inductor": {"library": "Device", "symbol": "L"}, - "diode": {"library": "Device", "symbol": "D"}, - "led": {"library": "Device", "symbol": "LED"}, - "transistor_npn": {"library": "Device", "symbol": "Q_NPN_BCE"}, - "transistor_pnp": {"library": "Device", "symbol": "Q_PNP_BCE"}, - "opamp": {"library": "Amplifier_Operational", "symbol": "OpAmp_Dual_Generic"}, - "microcontroller": {"library": "MCU_Module", "symbol": "Arduino_UNO_R3"}, - # Add more common components as needed - } - - # Normalize input to lowercase - component_type_lower = component_type.lower() - - # Try direct match first - if component_type_lower in common_mappings: - return common_mappings[component_type_lower] - - # Try partial matches - for key, value in common_mappings.items(): - if component_type_lower in key or key in component_type_lower: - return value - - # Default fallback - return {"library": "Device", "symbol": "R"} - - -if __name__ == "__main__": - # Example Usage (for testing) - # List available libraries - libraries = LibraryManager.list_available_libraries() - # Get default symbol for a component type - resistor_sym = LibraryManager.get_default_symbol_for_component_type("resistor") - print(f"Default symbol for resistor: {resistor_sym['library']}/{resistor_sym['symbol']}") - - # Try a partial match - cap_sym = LibraryManager.get_default_symbol_for_component_type("cap") - print(f"Default symbol for 'cap': {cap_sym['library']}/{cap_sym['symbol']}") +import glob +import logging + +# Symbol class might not be directly importable in the current version +import os +from typing import Any, Dict, List, Optional + +from skip import Schematic + +logger = logging.getLogger(__name__) + + +class LibraryManager: + """Manage symbol libraries""" + + @staticmethod + def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]: + """List all available symbol libraries""" + if search_paths is None: + # Default library paths based on common KiCAD installations + # This would need to be configured for the specific environment + search_paths = [ + "C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern + "/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern + "/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern + os.path.expanduser( + "~/Documents/KiCad/*/symbols/*.kicad_sym" + ), # User libraries pattern + ] + + libraries = [] + for path_pattern in search_paths: + try: + # Use glob to find all matching files + matching_libs = glob.glob(path_pattern, recursive=True) + libraries.extend(matching_libs) + except Exception as e: + logger.error(f"Error searching for libraries at {path_pattern}: {e}") + + # Extract library names from paths + library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries] + logger.info( + f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}" + ) + + # Return both full paths and library names + return {"paths": libraries, "names": library_names} + + @staticmethod + def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]: + """Get detailed information about a symbol""" + try: + logger.warning( + f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation." + ) + return {} + except Exception as e: + logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}") + return {} + + @staticmethod + def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]: + """Search for symbols matching criteria""" + try: + # This would typically involve: + # 1. Getting a list of all libraries using list_available_libraries + # 2. For each library, getting a list of all symbols + # 3. Filtering symbols based on the query + + # For now, this is a placeholder implementation + libraries = LibraryManager.list_available_libraries(search_paths) + + results = [] + logger.warning( + f"Searched for symbols matching '{query}'. This requires advanced implementation." + ) + return results + except Exception as e: + logger.error(f"Error searching for symbols matching '{query}': {e}") + return [] + + @staticmethod + def get_default_symbol_for_component_type( + component_type: str, search_paths: Optional[List[str]] = None + ) -> Dict[str, str]: + """Get a recommended default symbol for a given component type""" + # This method provides a simplified way to get a symbol for common component types + # It's useful when the user doesn't specify a particular library/symbol + + # Define common mappings from component type to library/symbol + common_mappings = { + "resistor": {"library": "Device", "symbol": "R"}, + "capacitor": {"library": "Device", "symbol": "C"}, + "inductor": {"library": "Device", "symbol": "L"}, + "diode": {"library": "Device", "symbol": "D"}, + "led": {"library": "Device", "symbol": "LED"}, + "transistor_npn": {"library": "Device", "symbol": "Q_NPN_BCE"}, + "transistor_pnp": {"library": "Device", "symbol": "Q_PNP_BCE"}, + "opamp": {"library": "Amplifier_Operational", "symbol": "OpAmp_Dual_Generic"}, + "microcontroller": {"library": "MCU_Module", "symbol": "Arduino_UNO_R3"}, + # Add more common components as needed + } + + # Normalize input to lowercase + component_type_lower = component_type.lower() + + # Try direct match first + if component_type_lower in common_mappings: + return common_mappings[component_type_lower] + + # Try partial matches + for key, value in common_mappings.items(): + if component_type_lower in key or key in component_type_lower: + return value + + # Default fallback + return {"library": "Device", "symbol": "R"} + + +if __name__ == "__main__": + # Example Usage (for testing) + # List available libraries + libraries = LibraryManager.list_available_libraries() + # Get default symbol for a component type + resistor_sym = LibraryManager.get_default_symbol_for_component_type("resistor") + print(f"Default symbol for resistor: {resistor_sym['library']}/{resistor_sym['symbol']}") + + # Try a partial match + cap_sym = LibraryManager.get_default_symbol_for_component_type("cap") + print(f"Default symbol for 'cap': {cap_sym['library']}/{cap_sym['symbol']}") diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py index 2ce4716..bfddca1 100644 --- a/python/commands/pin_locator.py +++ b/python/commands/pin_locator.py @@ -1,507 +1,507 @@ -""" -Pin Locator for KiCad Schematics - -Discovers pin locations on symbol instances, accounting for position, rotation, and mirroring. -Uses S-expression parsing to extract pin data from symbol definitions. -""" - -import logging -import math -import tempfile -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -import sexpdata -from sexpdata import Symbol -from skip import Schematic - -logger = logging.getLogger("kicad_interface") - - -class PinLocator: - """Locate pins on symbol instances in KiCad schematics""" - - def __init__(self) -> None: - """Initialize pin locator with empty cache""" - self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data - self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic - - @staticmethod - def parse_symbol_definition(symbol_def: list) -> Dict[str, Dict]: - """ - Parse a symbol definition from lib_symbols to extract pin information - - Args: - symbol_def: S-expression list representing symbol definition - - Returns: - Dictionary mapping pin number -> pin data: - { - "1": {"x": 0, "y": 3.81, "angle": 270, "length": 1.27, "name": "~", "type": "passive"}, - "2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"} - } - """ - pins: Dict[str, Dict[str, Any]] = {} - - def extract_pins_recursive(sexp: Any) -> None: - """Recursively search for pin definitions""" - if not isinstance(sexp, list): - return - - # Check if this is a pin definition - if len(sexp) > 0 and sexp[0] == Symbol("pin"): - # Pin format: (pin type shape (at x y angle) (length len) (name "name") (number "num")) - pin_data = { - "x": 0, - "y": 0, - "angle": 0, - "length": 0, - "name": "", - "number": "", - "type": str(sexp[1]) if len(sexp) > 1 else "passive", - } - - # Extract pin attributes - for item in sexp: - if isinstance(item, list) and len(item) > 0: - if item[0] == Symbol("at") and len(item) >= 3: - pin_data["x"] = float(item[1]) - pin_data["y"] = float(item[2]) - if len(item) >= 4: - pin_data["angle"] = float(item[3]) - - elif item[0] == Symbol("length") and len(item) >= 2: - pin_data["length"] = float(item[1]) - - elif item[0] == Symbol("name") and len(item) >= 2: - pin_data["name"] = str(item[1]).strip('"') - - elif item[0] == Symbol("number") and len(item) >= 2: - pin_data["number"] = str(item[1]).strip('"') - - # Store by pin number - if pin_data["number"]: - pins[pin_data["number"]] = pin_data - - # Recurse into sublists - for item in sexp: - if isinstance(item, list): - extract_pins_recursive(item) - - extract_pins_recursive(symbol_def) - return pins - - def get_symbol_pins(self, schematic_path: Path, lib_id: str) -> Dict[str, Dict]: - """ - Get pin definitions for a symbol from the schematic's lib_symbols section - - Args: - schematic_path: Path to .kicad_sch file - lib_id: Library identifier (e.g., "Device:R", "MCU_ST_STM32F1:STM32F103C8Tx") - - Returns: - Dictionary mapping pin number -> pin data - """ - # Check cache - cache_key = f"{schematic_path}:{lib_id}" - if cache_key in self.pin_definition_cache: - logger.debug(f"Using cached pin data for {lib_id}") - return self.pin_definition_cache[cache_key] - - try: - # Read schematic - with open(schematic_path, "r", encoding="utf-8") as f: - sch_content = f.read() - - sch_data = sexpdata.loads(sch_content) - - # Find lib_symbols section - lib_symbols = None - for item in sch_data: - if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"): - lib_symbols = item - break - - if not lib_symbols: - logger.error("No lib_symbols section found in schematic") - return {} - - # Find the specific symbol definition - for item in lib_symbols[1:]: # Skip 'lib_symbols' itself - if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"): - symbol_name = str(item[1]).strip('"') - if symbol_name == lib_id: - # Found the symbol, parse pins - pins = self.parse_symbol_definition(item) - self.pin_definition_cache[cache_key] = pins - logger.info(f"Extracted {len(pins)} pins from {lib_id}") - return pins - - logger.warning(f"Symbol {lib_id} not found in lib_symbols") - return {} - - except Exception as e: - logger.error(f"Error getting symbol pins: {e}") - import traceback - - logger.error(traceback.format_exc()) - return {} - - @staticmethod - def rotate_point(x: float, y: float, angle_degrees: float) -> Tuple[float, float]: - """ - Rotate a point around the origin - - Args: - x: X coordinate - y: Y coordinate - angle_degrees: Rotation angle in degrees (counterclockwise) - - Returns: - (rotated_x, rotated_y) - """ - if angle_degrees == 0: - return (x, y) - - angle_rad = math.radians(angle_degrees) - cos_a = math.cos(angle_rad) - sin_a = math.sin(angle_rad) - - rotated_x = x * cos_a - y * sin_a - rotated_y = x * sin_a + y * cos_a - - return (rotated_x, rotated_y) - - def _get_lib_id(self, schematic_path: Path, symbol_reference: str) -> Optional[str]: - """Helper: return the lib_id string for a placed symbol""" - try: - sch_key = str(schematic_path) - if sch_key not in self._schematic_cache: - self._schematic_cache[sch_key] = Schematic(sch_key) - sch = self._schematic_cache[sch_key] - for symbol in sch.symbol: - if symbol.property.Reference.value == symbol_reference: - return symbol.lib_id.value if hasattr(symbol, "lib_id") else None - except Exception: - pass - return None - - def get_pin_angle( - self, schematic_path: Path, symbol_reference: str, pin_number: str - ) -> Optional[float]: - """ - Get the outward angle of a pin endpoint in degrees (0=right, 90=up, 180=left, 270=down). - This is the direction a wire stub must extend to stay connected to the pin. - - Returns angle in degrees, or None if pin not found. - """ - try: - sch_key = str(schematic_path) - if sch_key not in self._schematic_cache: - self._schematic_cache[sch_key] = Schematic(sch_key) - sch = self._schematic_cache[sch_key] - - target_symbol = None - for symbol in sch.symbol: - if symbol.property.Reference.value == symbol_reference: - target_symbol = symbol - break - - if not target_symbol: - return None - - symbol_at = target_symbol.at.value - symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 - - lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None - if not lib_id: - return None - - pins = self.get_symbol_pins(schematic_path, lib_id) - if pin_number not in pins: - matched_num = next( - (num for num, data in pins.items() if data.get("name") == pin_number), - None, - ) - if matched_num: - pin_number = matched_num - else: - return None - - # Pin definition angle + symbol rotation = absolute outward direction - pin_def_angle = pins[pin_number].get("angle", 0) - absolute_angle = (pin_def_angle + symbol_rotation) % 360 - return absolute_angle - - except Exception: - return None - - def get_pin_location( - self, schematic_path: Path, symbol_reference: str, pin_number: str - ) -> Optional[List[float]]: - """ - Get the absolute location of a pin on a symbol instance - - Args: - schematic_path: Path to .kicad_sch file - symbol_reference: Symbol reference designator (e.g., "R1", "U1") - pin_number: Pin number/identifier (e.g., "1", "2", "GND", "VCC") - - Returns: - [x, y] absolute coordinates of the pin, or None if not found - """ - try: - # Load schematic with kicad-skip to get symbol instance - # Use cache to avoid reloading the file for every pin lookup - sch_key = str(schematic_path) - if sch_key not in self._schematic_cache: - self._schematic_cache[sch_key] = Schematic(sch_key) - sch = self._schematic_cache[sch_key] - - # Find the symbol instance - target_symbol = None - for symbol in sch.symbol: - ref = symbol.property.Reference.value - if ref == symbol_reference: - target_symbol = symbol - break - - if not target_symbol: - logger.error(f"Symbol {symbol_reference} not found in schematic") - return None - - # Get symbol position and rotation - symbol_at = target_symbol.at.value - symbol_x = float(symbol_at[0]) - symbol_y = float(symbol_at[1]) - symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 - - # Get symbol lib_id - lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None - if not lib_id: - logger.error(f"Symbol {symbol_reference} has no lib_id") - return None - - logger.debug( - f"Symbol {symbol_reference}: pos=({symbol_x}, {symbol_y}), rot={symbol_rotation}, lib_id={lib_id}" - ) - - # Get pin definitions for this symbol - pins = self.get_symbol_pins(schematic_path, lib_id) - if not pins: - logger.error(f"No pin definitions found for {lib_id}") - return None - - # Find the requested pin — match by number first, then by name - if pin_number not in pins: - # Try matching by pin name (e.g. "VCC1", "SDA", "GND") - matched_num = next( - (num for num, data in pins.items() if data.get("name") == pin_number), - None, - ) - if matched_num: - logger.debug( - f"Resolved pin name '{pin_number}' to pin number '{matched_num}' on {symbol_reference}" - ) - pin_number = matched_num - else: - logger.error( - f"Pin {pin_number} not found on {symbol_reference}. Available pins: {list(pins.keys())} " - f"(names: {[d.get('name','') for d in pins.values()]})" - ) - return None - - pin_data = pins[pin_number] - - # Get pin position relative to symbol origin - pin_rel_x = pin_data["x"] - pin_rel_y = pin_data["y"] - - logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})") - - # Apply symbol rotation to pin position - if symbol_rotation != 0: - pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation) - logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})") - - # Calculate absolute position - abs_x = symbol_x + pin_rel_x - abs_y = symbol_y + pin_rel_y - - logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})") - return [abs_x, abs_y] - - except Exception as e: - logger.error(f"Error getting pin location: {e}") - import traceback - - logger.error(traceback.format_exc()) - return None - - def get_all_symbol_pins( - self, schematic_path: Path, symbol_reference: str - ) -> Dict[str, List[float]]: - """ - Get locations of all pins on a symbol instance - - Args: - schematic_path: Path to .kicad_sch file - symbol_reference: Symbol reference designator (e.g., "R1", "U1") - - Returns: - Dictionary mapping pin number -> [x, y] coordinates - """ - try: - # Load schematic (use cache) - sch_key = str(schematic_path) - if sch_key not in self._schematic_cache: - self._schematic_cache[sch_key] = Schematic(sch_key) - sch = self._schematic_cache[sch_key] - - # Find symbol - target_symbol = None - for symbol in sch.symbol: - if symbol.property.Reference.value == symbol_reference: - target_symbol = symbol - break - - if not target_symbol: - logger.error(f"Symbol {symbol_reference} not found") - return {} - - # Get lib_id - lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None - if not lib_id: - logger.error(f"Symbol {symbol_reference} has no lib_id") - return {} - - # Get pin definitions - pins = self.get_symbol_pins(schematic_path, lib_id) - if not pins: - return {} - - # Calculate location for each pin - result = {} - for pin_num in pins.keys(): - location = self.get_pin_location(schematic_path, symbol_reference, pin_num) - if location: - result[pin_num] = location - - logger.info(f"Located {len(result)} pins on {symbol_reference}") - return result - - except Exception as e: - logger.error(f"Error getting all symbol pins: {e}") - return {} - - -if __name__ == "__main__": - # Test pin location discovery - import shutil - import sys - from pathlib import Path - - from commands.component_schematic import ComponentManager - from commands.schematic import SchematicManager - - sys.path.insert(0, str(Path(__file__).parent.parent)) - - print("=" * 80) - print("PIN LOCATOR TEST") - print("=" * 80) - - # Create test schematic with components (cross-platform temp directory) - test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch" - template_path = ( - Path(__file__).parent.parent / "templates" / "template_with_symbols_expanded.kicad_sch" - ) - - shutil.copy(template_path, test_path) - print(f"\n✓ Created test schematic: {test_path}") - - # Add some components - print("\n[1/4] Adding test components...") - sch = SchematicManager.load_schematic(str(test_path)) - - # Add resistor at (100, 100), rotation 0 - r1_def = { - "type": "R", - "reference": "R1", - "value": "10k", - "x": 100, - "y": 100, - "rotation": 0, - } - ComponentManager.add_component(sch, r1_def, test_path) - - # Add capacitor at (150, 100), rotation 90 - c1_def = { - "type": "C", - "reference": "C1", - "value": "100nF", - "x": 150, - "y": 100, - "rotation": 90, - } - ComponentManager.add_component(sch, c1_def, test_path) - - SchematicManager.save_schematic(sch, str(test_path)) - print(" ✓ Added R1 and C1") - - # Test pin locator - print("\n[2/4] Testing pin location discovery...") - locator = PinLocator() - - # Find R1 pins - r1_pin1 = locator.get_pin_location(test_path, "R1", "1") - r1_pin2 = locator.get_pin_location(test_path, "R1", "2") - - print(f" R1 pin 1: {r1_pin1}") - print(f" R1 pin 2: {r1_pin2}") - - # Find C1 pins (rotated 90 degrees) - c1_pin1 = locator.get_pin_location(test_path, "C1", "1") - c1_pin2 = locator.get_pin_location(test_path, "C1", "2") - - print(f" C1 pin 1: {c1_pin1}") - print(f" C1 pin 2: {c1_pin2}") - - # Test get all pins - print("\n[3/4] Testing get all pins...") - r1_all_pins = locator.get_all_symbol_pins(test_path, "R1") - print(f" R1 all pins: {r1_all_pins}") - - c1_all_pins = locator.get_all_symbol_pins(test_path, "C1") - print(f" C1 all pins: {c1_all_pins}") - - # Verify results - print("\n[4/4] Verification...") - success = True - - if not r1_pin1 or not r1_pin2: - print(" ✗ Failed to locate R1 pins") - success = False - else: - print(" ✓ R1 pins located") - - if not c1_pin1 or not c1_pin2: - print(" ✗ Failed to locate C1 pins") - success = False - else: - print(" ✓ C1 pins located") - - # Check rotation (C1 pins should be rotated 90 degrees from R1) - if r1_pin1 and c1_pin1: - # R1 is not rotated, pins should be at y offset from symbol center - # C1 is rotated 90°, pins should be at x offset from symbol center - print(f"\n Pin offset analysis:") - print(f" R1 (0°): pin 1 y-offset = {r1_pin1[1] - 100}") - print(f" C1 (90°): pin 1 x-offset = {c1_pin1[0] - 150}") - - print("\n" + "=" * 80) - if success: - print("✅ PIN LOCATOR TEST PASSED!") - else: - print("❌ PIN LOCATOR TEST FAILED!") - print("=" * 80) - print(f"\nTest schematic saved: {test_path}") +""" +Pin Locator for KiCad Schematics + +Discovers pin locations on symbol instances, accounting for position, rotation, and mirroring. +Uses S-expression parsing to extract pin data from symbol definitions. +""" + +import logging +import math +import tempfile +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import sexpdata +from sexpdata import Symbol +from skip import Schematic + +logger = logging.getLogger("kicad_interface") + + +class PinLocator: + """Locate pins on symbol instances in KiCad schematics""" + + def __init__(self) -> None: + """Initialize pin locator with empty cache""" + self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data + self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic + + @staticmethod + def parse_symbol_definition(symbol_def: list) -> Dict[str, Dict]: + """ + Parse a symbol definition from lib_symbols to extract pin information + + Args: + symbol_def: S-expression list representing symbol definition + + Returns: + Dictionary mapping pin number -> pin data: + { + "1": {"x": 0, "y": 3.81, "angle": 270, "length": 1.27, "name": "~", "type": "passive"}, + "2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"} + } + """ + pins: Dict[str, Dict[str, Any]] = {} + + def extract_pins_recursive(sexp: Any) -> None: + """Recursively search for pin definitions""" + if not isinstance(sexp, list): + return + + # Check if this is a pin definition + if len(sexp) > 0 and sexp[0] == Symbol("pin"): + # Pin format: (pin type shape (at x y angle) (length len) (name "name") (number "num")) + pin_data = { + "x": 0, + "y": 0, + "angle": 0, + "length": 0, + "name": "", + "number": "", + "type": str(sexp[1]) if len(sexp) > 1 else "passive", + } + + # Extract pin attributes + for item in sexp: + if isinstance(item, list) and len(item) > 0: + if item[0] == Symbol("at") and len(item) >= 3: + pin_data["x"] = float(item[1]) + pin_data["y"] = float(item[2]) + if len(item) >= 4: + pin_data["angle"] = float(item[3]) + + elif item[0] == Symbol("length") and len(item) >= 2: + pin_data["length"] = float(item[1]) + + elif item[0] == Symbol("name") and len(item) >= 2: + pin_data["name"] = str(item[1]).strip('"') + + elif item[0] == Symbol("number") and len(item) >= 2: + pin_data["number"] = str(item[1]).strip('"') + + # Store by pin number + if pin_data["number"]: + pins[pin_data["number"]] = pin_data + + # Recurse into sublists + for item in sexp: + if isinstance(item, list): + extract_pins_recursive(item) + + extract_pins_recursive(symbol_def) + return pins + + def get_symbol_pins(self, schematic_path: Path, lib_id: str) -> Dict[str, Dict]: + """ + Get pin definitions for a symbol from the schematic's lib_symbols section + + Args: + schematic_path: Path to .kicad_sch file + lib_id: Library identifier (e.g., "Device:R", "MCU_ST_STM32F1:STM32F103C8Tx") + + Returns: + Dictionary mapping pin number -> pin data + """ + # Check cache + cache_key = f"{schematic_path}:{lib_id}" + if cache_key in self.pin_definition_cache: + logger.debug(f"Using cached pin data for {lib_id}") + return self.pin_definition_cache[cache_key] + + try: + # Read schematic + with open(schematic_path, "r", encoding="utf-8") as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + # Find lib_symbols section + lib_symbols = None + for item in sch_data: + if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"): + lib_symbols = item + break + + if not lib_symbols: + logger.error("No lib_symbols section found in schematic") + return {} + + # Find the specific symbol definition + for item in lib_symbols[1:]: # Skip 'lib_symbols' itself + if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"): + symbol_name = str(item[1]).strip('"') + if symbol_name == lib_id: + # Found the symbol, parse pins + pins = self.parse_symbol_definition(item) + self.pin_definition_cache[cache_key] = pins + logger.info(f"Extracted {len(pins)} pins from {lib_id}") + return pins + + logger.warning(f"Symbol {lib_id} not found in lib_symbols") + return {} + + except Exception as e: + logger.error(f"Error getting symbol pins: {e}") + import traceback + + logger.error(traceback.format_exc()) + return {} + + @staticmethod + def rotate_point(x: float, y: float, angle_degrees: float) -> Tuple[float, float]: + """ + Rotate a point around the origin + + Args: + x: X coordinate + y: Y coordinate + angle_degrees: Rotation angle in degrees (counterclockwise) + + Returns: + (rotated_x, rotated_y) + """ + if angle_degrees == 0: + return (x, y) + + angle_rad = math.radians(angle_degrees) + cos_a = math.cos(angle_rad) + sin_a = math.sin(angle_rad) + + rotated_x = x * cos_a - y * sin_a + rotated_y = x * sin_a + y * cos_a + + return (rotated_x, rotated_y) + + def _get_lib_id(self, schematic_path: Path, symbol_reference: str) -> Optional[str]: + """Helper: return the lib_id string for a placed symbol""" + try: + sch_key = str(schematic_path) + if sch_key not in self._schematic_cache: + self._schematic_cache[sch_key] = Schematic(sch_key) + sch = self._schematic_cache[sch_key] + for symbol in sch.symbol: + if symbol.property.Reference.value == symbol_reference: + return symbol.lib_id.value if hasattr(symbol, "lib_id") else None + except Exception: + pass + return None + + def get_pin_angle( + self, schematic_path: Path, symbol_reference: str, pin_number: str + ) -> Optional[float]: + """ + Get the outward angle of a pin endpoint in degrees (0=right, 90=up, 180=left, 270=down). + This is the direction a wire stub must extend to stay connected to the pin. + + Returns angle in degrees, or None if pin not found. + """ + try: + sch_key = str(schematic_path) + if sch_key not in self._schematic_cache: + self._schematic_cache[sch_key] = Schematic(sch_key) + sch = self._schematic_cache[sch_key] + + target_symbol = None + for symbol in sch.symbol: + if symbol.property.Reference.value == symbol_reference: + target_symbol = symbol + break + + if not target_symbol: + return None + + symbol_at = target_symbol.at.value + symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 + + lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None + if not lib_id: + return None + + pins = self.get_symbol_pins(schematic_path, lib_id) + if pin_number not in pins: + matched_num = next( + (num for num, data in pins.items() if data.get("name") == pin_number), + None, + ) + if matched_num: + pin_number = matched_num + else: + return None + + # Pin definition angle + symbol rotation = absolute outward direction + pin_def_angle = pins[pin_number].get("angle", 0) + absolute_angle = (pin_def_angle + symbol_rotation) % 360 + return absolute_angle + + except Exception: + return None + + def get_pin_location( + self, schematic_path: Path, symbol_reference: str, pin_number: str + ) -> Optional[List[float]]: + """ + Get the absolute location of a pin on a symbol instance + + Args: + schematic_path: Path to .kicad_sch file + symbol_reference: Symbol reference designator (e.g., "R1", "U1") + pin_number: Pin number/identifier (e.g., "1", "2", "GND", "VCC") + + Returns: + [x, y] absolute coordinates of the pin, or None if not found + """ + try: + # Load schematic with kicad-skip to get symbol instance + # Use cache to avoid reloading the file for every pin lookup + sch_key = str(schematic_path) + if sch_key not in self._schematic_cache: + self._schematic_cache[sch_key] = Schematic(sch_key) + sch = self._schematic_cache[sch_key] + + # Find the symbol instance + target_symbol = None + for symbol in sch.symbol: + ref = symbol.property.Reference.value + if ref == symbol_reference: + target_symbol = symbol + break + + if not target_symbol: + logger.error(f"Symbol {symbol_reference} not found in schematic") + return None + + # Get symbol position and rotation + symbol_at = target_symbol.at.value + symbol_x = float(symbol_at[0]) + symbol_y = float(symbol_at[1]) + symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 + + # Get symbol lib_id + lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None + if not lib_id: + logger.error(f"Symbol {symbol_reference} has no lib_id") + return None + + logger.debug( + f"Symbol {symbol_reference}: pos=({symbol_x}, {symbol_y}), rot={symbol_rotation}, lib_id={lib_id}" + ) + + # Get pin definitions for this symbol + pins = self.get_symbol_pins(schematic_path, lib_id) + if not pins: + logger.error(f"No pin definitions found for {lib_id}") + return None + + # Find the requested pin — match by number first, then by name + if pin_number not in pins: + # Try matching by pin name (e.g. "VCC1", "SDA", "GND") + matched_num = next( + (num for num, data in pins.items() if data.get("name") == pin_number), + None, + ) + if matched_num: + logger.debug( + f"Resolved pin name '{pin_number}' to pin number '{matched_num}' on {symbol_reference}" + ) + pin_number = matched_num + else: + logger.error( + f"Pin {pin_number} not found on {symbol_reference}. Available pins: {list(pins.keys())} " + f"(names: {[d.get('name','') for d in pins.values()]})" + ) + return None + + pin_data = pins[pin_number] + + # Get pin position relative to symbol origin + pin_rel_x = pin_data["x"] + pin_rel_y = pin_data["y"] + + logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})") + + # Apply symbol rotation to pin position + if symbol_rotation != 0: + pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation) + logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})") + + # Calculate absolute position + abs_x = symbol_x + pin_rel_x + abs_y = symbol_y + pin_rel_y + + logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})") + return [abs_x, abs_y] + + except Exception as e: + logger.error(f"Error getting pin location: {e}") + import traceback + + logger.error(traceback.format_exc()) + return None + + def get_all_symbol_pins( + self, schematic_path: Path, symbol_reference: str + ) -> Dict[str, List[float]]: + """ + Get locations of all pins on a symbol instance + + Args: + schematic_path: Path to .kicad_sch file + symbol_reference: Symbol reference designator (e.g., "R1", "U1") + + Returns: + Dictionary mapping pin number -> [x, y] coordinates + """ + try: + # Load schematic (use cache) + sch_key = str(schematic_path) + if sch_key not in self._schematic_cache: + self._schematic_cache[sch_key] = Schematic(sch_key) + sch = self._schematic_cache[sch_key] + + # Find symbol + target_symbol = None + for symbol in sch.symbol: + if symbol.property.Reference.value == symbol_reference: + target_symbol = symbol + break + + if not target_symbol: + logger.error(f"Symbol {symbol_reference} not found") + return {} + + # Get lib_id + lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None + if not lib_id: + logger.error(f"Symbol {symbol_reference} has no lib_id") + return {} + + # Get pin definitions + pins = self.get_symbol_pins(schematic_path, lib_id) + if not pins: + return {} + + # Calculate location for each pin + result = {} + for pin_num in pins.keys(): + location = self.get_pin_location(schematic_path, symbol_reference, pin_num) + if location: + result[pin_num] = location + + logger.info(f"Located {len(result)} pins on {symbol_reference}") + return result + + except Exception as e: + logger.error(f"Error getting all symbol pins: {e}") + return {} + + +if __name__ == "__main__": + # Test pin location discovery + import shutil + import sys + from pathlib import Path + + from commands.component_schematic import ComponentManager + from commands.schematic import SchematicManager + + sys.path.insert(0, str(Path(__file__).parent.parent)) + + print("=" * 80) + print("PIN LOCATOR TEST") + print("=" * 80) + + # Create test schematic with components (cross-platform temp directory) + test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch" + template_path = ( + Path(__file__).parent.parent / "templates" / "template_with_symbols_expanded.kicad_sch" + ) + + shutil.copy(template_path, test_path) + print(f"\n✓ Created test schematic: {test_path}") + + # Add some components + print("\n[1/4] Adding test components...") + sch = SchematicManager.load_schematic(str(test_path)) + + # Add resistor at (100, 100), rotation 0 + r1_def = { + "type": "R", + "reference": "R1", + "value": "10k", + "x": 100, + "y": 100, + "rotation": 0, + } + ComponentManager.add_component(sch, r1_def, test_path) + + # Add capacitor at (150, 100), rotation 90 + c1_def = { + "type": "C", + "reference": "C1", + "value": "100nF", + "x": 150, + "y": 100, + "rotation": 90, + } + ComponentManager.add_component(sch, c1_def, test_path) + + SchematicManager.save_schematic(sch, str(test_path)) + print(" ✓ Added R1 and C1") + + # Test pin locator + print("\n[2/4] Testing pin location discovery...") + locator = PinLocator() + + # Find R1 pins + r1_pin1 = locator.get_pin_location(test_path, "R1", "1") + r1_pin2 = locator.get_pin_location(test_path, "R1", "2") + + print(f" R1 pin 1: {r1_pin1}") + print(f" R1 pin 2: {r1_pin2}") + + # Find C1 pins (rotated 90 degrees) + c1_pin1 = locator.get_pin_location(test_path, "C1", "1") + c1_pin2 = locator.get_pin_location(test_path, "C1", "2") + + print(f" C1 pin 1: {c1_pin1}") + print(f" C1 pin 2: {c1_pin2}") + + # Test get all pins + print("\n[3/4] Testing get all pins...") + r1_all_pins = locator.get_all_symbol_pins(test_path, "R1") + print(f" R1 all pins: {r1_all_pins}") + + c1_all_pins = locator.get_all_symbol_pins(test_path, "C1") + print(f" C1 all pins: {c1_all_pins}") + + # Verify results + print("\n[4/4] Verification...") + success = True + + if not r1_pin1 or not r1_pin2: + print(" ✗ Failed to locate R1 pins") + success = False + else: + print(" ✓ R1 pins located") + + if not c1_pin1 or not c1_pin2: + print(" ✗ Failed to locate C1 pins") + success = False + else: + print(" ✓ C1 pins located") + + # Check rotation (C1 pins should be rotated 90 degrees from R1) + if r1_pin1 and c1_pin1: + # R1 is not rotated, pins should be at y offset from symbol center + # C1 is rotated 90°, pins should be at x offset from symbol center + print(f"\n Pin offset analysis:") + print(f" R1 (0°): pin 1 y-offset = {r1_pin1[1] - 100}") + print(f" C1 (90°): pin 1 x-offset = {c1_pin1[0] - 150}") + + print("\n" + "=" * 80) + if success: + print("✅ PIN LOCATOR TEST PASSED!") + else: + print("❌ PIN LOCATOR TEST FAILED!") + print("=" * 80) + print(f"\nTest schematic saved: {test_path}") diff --git a/python/commands/project.py b/python/commands/project.py index 2b82697..0447ab2 100644 --- a/python/commands/project.py +++ b/python/commands/project.py @@ -1,255 +1,255 @@ -""" -Project-related command implementations for KiCAD interface -""" - -import logging -import os -import shutil -from typing import Any, Dict, Optional - -import pcbnew # type: ignore - -logger = logging.getLogger("kicad_interface") - - -class ProjectCommands: - """Handles project-related KiCAD operations""" - - def __init__(self, board: Optional[pcbnew.BOARD] = None): - """Initialize with optional board instance""" - self.board = board - - def create_project(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Create a new KiCAD project""" - try: - # Accept both 'name' (from MCP tool) and 'projectName' (legacy) - project_name = params.get("name") or params.get("projectName", "New_Project") - path = params.get("path", os.getcwd()) - template = params.get("template") - - # Generate the full project path - project_path = os.path.join(path, project_name) - if not project_path.endswith(".kicad_pro"): - project_path += ".kicad_pro" - - # Create project directory if it doesn't exist - os.makedirs(os.path.dirname(project_path), exist_ok=True) - - # Create a new board - board = pcbnew.BOARD() - - # Set project properties - board.GetTitleBlock().SetTitle(project_name) - - # Set current date with proper parameter - from datetime import datetime - - current_date = datetime.now().strftime("%Y-%m-%d") - board.GetTitleBlock().SetDate(current_date) - - # If template is specified, try to load it - if template: - template_path = os.path.expanduser(template) - if os.path.exists(template_path): - template_board = pcbnew.LoadBoard(template_path) - # Copy settings from template - board.SetDesignSettings(template_board.GetDesignSettings()) - board.SetLayerStack(template_board.GetLayerStack()) - - # Save the board - board_path = project_path.replace(".kicad_pro", ".kicad_pcb") - board.SetFileName(board_path) - pcbnew.SaveBoard(board_path, board) - - # Create schematic from template (use expanded template with symbol definitions) - schematic_path = project_path.replace(".kicad_pro", ".kicad_sch") - template_sch_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "..", - "templates", - "template_with_symbols_expanded.kicad_sch", - ) - - if os.path.exists(template_sch_path): - # Copy template schematic - shutil.copy(template_sch_path, schematic_path) - - # Regenerate UUID to ensure uniqueness for each created project - import re - import uuid as uuid_module - - with open(schematic_path, "r", encoding="utf-8") as f: - content = f.read() - new_uuid = str(uuid_module.uuid4()) - content = re.sub( - r"\(uuid [0-9a-fA-F-]+\)", - f"(uuid {new_uuid})", - content, - count=1, # Only replace first (schematic) UUID - ) - with open(schematic_path, "w", encoding="utf-8", newline="\n") as f: - f.write(content) - - logger.info(f"Created schematic from template: {schematic_path}") - else: - # Fallback: create minimal schematic - logger.warning( - f"Template not found at {template_sch_path}, creating minimal schematic" - ) - import uuid as uuid_module - - schematic_uuid = str(uuid_module.uuid4()) - with open(schematic_path, "w", encoding="utf-8", newline="\n") as f: - f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n') - f.write(f" (uuid {schematic_uuid})\n\n") - f.write(' (paper "A4")\n\n') - f.write(" (lib_symbols\n )\n\n") - f.write(' (sheet_instances\n (path "/" (page "1"))\n )\n') - f.write(")\n") - - # Create project file with schematic reference - with open(project_path, "w") as f: - f.write("{\n") - f.write(' "board": {\n') - f.write(f' "filename": "{os.path.basename(board_path)}"\n') - f.write(" },\n") - f.write(' "sheets": [\n') - f.write(f' ["root", "{os.path.basename(schematic_path)}"]\n') - f.write(" ]\n") - f.write("}\n") - - self.board = board - - return { - "success": True, - "message": f"Created project: {project_name}", - "project": { - "name": project_name, - "path": project_path, - "boardPath": board_path, - "schematicPath": schematic_path, - }, - } - - except Exception as e: - logger.error(f"Error creating project: {str(e)}") - return { - "success": False, - "message": "Failed to create project", - "errorDetails": str(e), - } - - def open_project(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Open an existing KiCAD project""" - try: - filename = params.get("filename") - if not filename: - return { - "success": False, - "message": "No filename provided", - "errorDetails": "The filename parameter is required", - } - - # Expand user path and make absolute - filename = os.path.abspath(os.path.expanduser(filename)) - - # If it's a project file, get the board file - if filename.endswith(".kicad_pro"): - board_path = filename.replace(".kicad_pro", ".kicad_pcb") - else: - board_path = filename - - # Load the board - board = pcbnew.LoadBoard(board_path) - self.board = board - - return { - "success": True, - "message": f"Opened project: {os.path.basename(board_path)}", - "project": { - "name": os.path.splitext(os.path.basename(board_path))[0], - "path": filename, - "boardPath": board_path, - }, - } - - except Exception as e: - logger.error(f"Error opening project: {str(e)}") - return { - "success": False, - "message": "Failed to open project", - "errorDetails": str(e), - } - - def save_project(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Save the current KiCAD project""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - filename = params.get("filename") - if filename: - # Save to new location - filename = os.path.abspath(os.path.expanduser(filename)) - self.board.SetFileName(filename) - - # Save the board - pcbnew.SaveBoard(self.board.GetFileName(), self.board) - - return { - "success": True, - "message": f"Saved project to: {self.board.GetFileName()}", - "project": { - "name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0], - "path": self.board.GetFileName(), - }, - } - - except Exception as e: - logger.error(f"Error saving project: {str(e)}") - return { - "success": False, - "message": "Failed to save project", - "errorDetails": str(e), - } - - def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get information about the current project""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - title_block = self.board.GetTitleBlock() - filename = self.board.GetFileName() - - return { - "success": True, - "project": { - "name": os.path.splitext(os.path.basename(filename))[0], - "path": filename, - "title": title_block.GetTitle(), - "date": title_block.GetDate(), - "revision": title_block.GetRevision(), - "company": title_block.GetCompany(), - "comment1": title_block.GetComment(0), - "comment2": title_block.GetComment(1), - "comment3": title_block.GetComment(2), - "comment4": title_block.GetComment(3), - }, - } - - except Exception as e: - logger.error(f"Error getting project info: {str(e)}") - return { - "success": False, - "message": "Failed to get project information", - "errorDetails": str(e), - } +""" +Project-related command implementations for KiCAD interface +""" + +import logging +import os +import shutil +from typing import Any, Dict, Optional + +import pcbnew # type: ignore + +logger = logging.getLogger("kicad_interface") + + +class ProjectCommands: + """Handles project-related KiCAD operations""" + + def __init__(self, board: Optional[pcbnew.BOARD] = None): + """Initialize with optional board instance""" + self.board = board + + def create_project(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Create a new KiCAD project""" + try: + # Accept both 'name' (from MCP tool) and 'projectName' (legacy) + project_name = params.get("name") or params.get("projectName", "New_Project") + path = params.get("path", os.getcwd()) + template = params.get("template") + + # Generate the full project path + project_path = os.path.join(path, project_name) + if not project_path.endswith(".kicad_pro"): + project_path += ".kicad_pro" + + # Create project directory if it doesn't exist + os.makedirs(os.path.dirname(project_path), exist_ok=True) + + # Create a new board + board = pcbnew.BOARD() + + # Set project properties + board.GetTitleBlock().SetTitle(project_name) + + # Set current date with proper parameter + from datetime import datetime + + current_date = datetime.now().strftime("%Y-%m-%d") + board.GetTitleBlock().SetDate(current_date) + + # If template is specified, try to load it + if template: + template_path = os.path.expanduser(template) + if os.path.exists(template_path): + template_board = pcbnew.LoadBoard(template_path) + # Copy settings from template + board.SetDesignSettings(template_board.GetDesignSettings()) + board.SetLayerStack(template_board.GetLayerStack()) + + # Save the board + board_path = project_path.replace(".kicad_pro", ".kicad_pcb") + board.SetFileName(board_path) + pcbnew.SaveBoard(board_path, board) + + # Create schematic from template (use expanded template with symbol definitions) + schematic_path = project_path.replace(".kicad_pro", ".kicad_sch") + template_sch_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", + "templates", + "template_with_symbols_expanded.kicad_sch", + ) + + if os.path.exists(template_sch_path): + # Copy template schematic + shutil.copy(template_sch_path, schematic_path) + + # Regenerate UUID to ensure uniqueness for each created project + import re + import uuid as uuid_module + + with open(schematic_path, "r", encoding="utf-8") as f: + content = f.read() + new_uuid = str(uuid_module.uuid4()) + content = re.sub( + r"\(uuid [0-9a-fA-F-]+\)", + f"(uuid {new_uuid})", + content, + count=1, # Only replace first (schematic) UUID + ) + with open(schematic_path, "w", encoding="utf-8", newline="\n") as f: + f.write(content) + + logger.info(f"Created schematic from template: {schematic_path}") + else: + # Fallback: create minimal schematic + logger.warning( + f"Template not found at {template_sch_path}, creating minimal schematic" + ) + import uuid as uuid_module + + schematic_uuid = str(uuid_module.uuid4()) + with open(schematic_path, "w", encoding="utf-8", newline="\n") as f: + f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n') + f.write(f" (uuid {schematic_uuid})\n\n") + f.write(' (paper "A4")\n\n') + f.write(" (lib_symbols\n )\n\n") + f.write(' (sheet_instances\n (path "/" (page "1"))\n )\n') + f.write(")\n") + + # Create project file with schematic reference + with open(project_path, "w") as f: + f.write("{\n") + f.write(' "board": {\n') + f.write(f' "filename": "{os.path.basename(board_path)}"\n') + f.write(" },\n") + f.write(' "sheets": [\n') + f.write(f' ["root", "{os.path.basename(schematic_path)}"]\n') + f.write(" ]\n") + f.write("}\n") + + self.board = board + + return { + "success": True, + "message": f"Created project: {project_name}", + "project": { + "name": project_name, + "path": project_path, + "boardPath": board_path, + "schematicPath": schematic_path, + }, + } + + except Exception as e: + logger.error(f"Error creating project: {str(e)}") + return { + "success": False, + "message": "Failed to create project", + "errorDetails": str(e), + } + + def open_project(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Open an existing KiCAD project""" + try: + filename = params.get("filename") + if not filename: + return { + "success": False, + "message": "No filename provided", + "errorDetails": "The filename parameter is required", + } + + # Expand user path and make absolute + filename = os.path.abspath(os.path.expanduser(filename)) + + # If it's a project file, get the board file + if filename.endswith(".kicad_pro"): + board_path = filename.replace(".kicad_pro", ".kicad_pcb") + else: + board_path = filename + + # Load the board + board = pcbnew.LoadBoard(board_path) + self.board = board + + return { + "success": True, + "message": f"Opened project: {os.path.basename(board_path)}", + "project": { + "name": os.path.splitext(os.path.basename(board_path))[0], + "path": filename, + "boardPath": board_path, + }, + } + + except Exception as e: + logger.error(f"Error opening project: {str(e)}") + return { + "success": False, + "message": "Failed to open project", + "errorDetails": str(e), + } + + def save_project(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Save the current KiCAD project""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + filename = params.get("filename") + if filename: + # Save to new location + filename = os.path.abspath(os.path.expanduser(filename)) + self.board.SetFileName(filename) + + # Save the board + pcbnew.SaveBoard(self.board.GetFileName(), self.board) + + return { + "success": True, + "message": f"Saved project to: {self.board.GetFileName()}", + "project": { + "name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0], + "path": self.board.GetFileName(), + }, + } + + except Exception as e: + logger.error(f"Error saving project: {str(e)}") + return { + "success": False, + "message": "Failed to save project", + "errorDetails": str(e), + } + + def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get information about the current project""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + title_block = self.board.GetTitleBlock() + filename = self.board.GetFileName() + + return { + "success": True, + "project": { + "name": os.path.splitext(os.path.basename(filename))[0], + "path": filename, + "title": title_block.GetTitle(), + "date": title_block.GetDate(), + "revision": title_block.GetRevision(), + "company": title_block.GetCompany(), + "comment1": title_block.GetComment(0), + "comment2": title_block.GetComment(1), + "comment3": title_block.GetComment(2), + "comment4": title_block.GetComment(3), + }, + } + + except Exception as e: + logger.error(f"Error getting project info: {str(e)}") + return { + "success": False, + "message": "Failed to get project information", + "errorDetails": str(e), + } diff --git a/python/commands/routing.py b/python/commands/routing.py index 9accea3..2ffa538 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -1,1431 +1,1431 @@ -""" -Routing-related command implementations for KiCAD interface -""" - -import logging -import math -import os -from typing import Any, Dict, List, Optional, Tuple - -import pcbnew - -logger = logging.getLogger("kicad_interface") - - -class RoutingCommands: - """Handles routing-related KiCAD operations""" - - def __init__(self, board: Optional[pcbnew.BOARD] = None): - """Initialize with optional board instance""" - self.board = board - - def add_net(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a new net to the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - name = params.get("name") - net_class = params.get("class") - - if not name: - return { - "success": False, - "message": "Missing net name", - "errorDetails": "name parameter is required", - } - - # Create new net - netinfo = self.board.GetNetInfo() - nets_map = netinfo.NetsByName() - if nets_map.has_key(name): - net = nets_map[name] - else: - net = pcbnew.NETINFO_ITEM(self.board, name) - self.board.Add(net) - - # Set net class if provided - if net_class: - net_classes = self.board.GetNetClasses() - if net_classes.Find(net_class): - net.SetClass(net_classes.Find(net_class)) - - return { - "success": True, - "message": f"Added net: {name}", - "net": { - "name": name, - "class": net_class if net_class else "Default", - "netcode": net.GetNetCode(), - }, - } - - except Exception as e: - logger.error(f"Error adding net: {str(e)}") - return { - "success": False, - "message": "Failed to add net", - "errorDetails": str(e), - } - - def route_pad_to_pad(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Route a trace directly from one component pad to another. - - Looks up pad positions automatically, then creates a trace. - Convenience wrapper around route_trace that eliminates the need - for separate get_pad_position calls. - """ - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - from_ref = params.get("fromRef") - from_pad = str(params.get("fromPad", "")) - to_ref = params.get("toRef") - to_pad = str(params.get("toPad", "")) - layer = params.get("layer", "F.Cu") - width = params.get("width") - net = params.get("net") # optional override - - if not from_ref or not from_pad or not to_ref or not to_pad: - return { - "success": False, - "message": "Missing parameters", - "errorDetails": "fromRef, fromPad, toRef, toPad are all required", - } - - scale = 1000000 # nm to mm - - # Find pads - footprints = {fp.GetReference(): fp for fp in self.board.GetFootprints()} - - for ref in [from_ref, to_ref]: - if ref not in footprints: - return { - "success": False, - "message": f"Component not found: {ref}", - "errorDetails": f"'{ref}' does not exist on the board", - } - - def find_pad(ref: str, pad_num: str) -> Any: - fp = footprints[ref] - for pad in fp.Pads(): - if pad.GetNumber() == pad_num: - return pad - return None - - start_pad = find_pad(from_ref, from_pad) - end_pad = find_pad(to_ref, to_pad) - - if not start_pad: - return { - "success": False, - "message": f"Pad not found: {from_ref} pad {from_pad}", - "errorDetails": f"Check pad number for {from_ref}", - } - if not end_pad: - return { - "success": False, - "message": f"Pad not found: {to_ref} pad {to_pad}", - "errorDetails": f"Check pad number for {to_ref}", - } - - start_pos = start_pad.GetPosition() - end_pos = end_pad.GetPosition() - - # Use net from start pad if not overridden - if not net: - net = start_pad.GetNetname() or end_pad.GetNetname() or "" - - # Detect if pads are on different copper layers → need via. - # SMD pad.GetLayer() reports F.Cu even on flipped B.Cu footprints in - # KiCAD 9 SWIG. Use footprint.GetLayer() instead — it always reflects - # the actual placed layer after Flip(). - fp_start = footprints[from_ref] - fp_end = footprints[to_ref] - start_layer = self.board.GetLayerName(fp_start.GetLayer()) - end_layer = self.board.GetLayerName(fp_end.GetLayer()) - copper_layers = {"F.Cu", "B.Cu"} - needs_via = ( - start_layer in copper_layers - and end_layer in copper_layers - and start_layer != end_layer - ) - - if needs_via: - # Place via directly below the start pad (same X). - # Using the geometric midpoint X causes all vias to stack at - # the same X when pads are back-to-back mirrored (e.g. J1/J2 - # on F.Cu/B.Cu): midpoint is always the board center. - via_x = start_pos.x / scale - via_y = (start_pos.y + end_pos.y) / 2 / scale - - # Trace on start layer: start_pad → via - r1 = self.route_trace( - { - "start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"}, - "end": {"x": via_x, "y": via_y, "unit": "mm"}, - "layer": start_layer, - "width": width, - "net": net, - } - ) - # Via connecting both layers - self.add_via( - { - "position": {"x": via_x, "y": via_y, "unit": "mm"}, - "net": net, - "from_layer": start_layer, - "to_layer": end_layer, - } - ) - # Trace on end layer: via → end_pad - r2 = self.route_trace( - { - "start": {"x": via_x, "y": via_y, "unit": "mm"}, - "end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"}, - "layer": end_layer, - "width": width, - "net": net, - } - ) - success = r1.get("success") and r2.get("success") - result = { - "success": success, - "message": f"Routed {from_ref}.{from_pad} → via → {to_ref}.{to_pad} (net: {net}, via at {via_x:.2f},{via_y:.2f})", - "via_added": True, - "via_position": {"x": via_x, "y": via_y}, - } - else: - # Same layer — direct 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 if layer else start_layer, - "width": width, - "net": net, - } - ) - - if result.get("success"): - 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 - - except Exception as e: - logger.error(f"Error in route_pad_to_pad: {str(e)}") - return { - "success": False, - "message": "Failed to route pad to pad", - "errorDetails": str(e), - } - - def route_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Route a trace between two points or pads""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - start = params.get("start") - end = params.get("end") - layer = params.get("layer", "F.Cu") - width = params.get("width") - net = params.get("net") - via = params.get("via", False) - - if not start or not end: - return { - "success": False, - "message": "Missing parameters", - "errorDetails": "start and end points are required", - } - - # Get layer ID - layer_id = self.board.GetLayerID(layer) - if layer_id < 0: - return { - "success": False, - "message": "Invalid layer", - "errorDetails": f"Layer '{layer}' does not exist", - } - - # Get start point - start_point = self._get_point(start) - end_point = self._get_point(end) - - # Create track segment - track = pcbnew.PCB_TRACK(self.board) - track.SetStart(start_point) - track.SetEnd(end_point) - track.SetLayer(layer_id) - - # Set width (default to board's current track width) - if width: - track.SetWidth(int(width * 1000000)) # Convert mm to nm - else: - track.SetWidth(self.board.GetDesignSettings().GetCurrentTrackWidth()) - - # Set net if provided - if net: - netinfo = self.board.GetNetInfo() - nets_map = netinfo.NetsByName() - if nets_map.has_key(net): - net_obj = nets_map[net] - track.SetNet(net_obj) - - # Add track to board - self.board.Add(track) - - # Add via if requested and net is specified - if via and net: - via_point = end_point - self.add_via( - { - "position": { - "x": via_point.x / 1000000, - "y": via_point.y / 1000000, - "unit": "mm", - }, - "net": net, - } - ) - - return { - "success": True, - "message": "Added trace", - "trace": { - "start": { - "x": start_point.x / 1000000, - "y": start_point.y / 1000000, - "unit": "mm", - }, - "end": { - "x": end_point.x / 1000000, - "y": end_point.y / 1000000, - "unit": "mm", - }, - "layer": layer, - "width": track.GetWidth() / 1000000, - "net": net, - }, - } - - except Exception as e: - logger.error(f"Error routing trace: {str(e)}") - return { - "success": False, - "message": "Failed to route trace", - "errorDetails": str(e), - } - - def add_via(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a via at the specified location""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - position = params.get("position") - size = params.get("size") - drill = params.get("drill") - net = params.get("net") - from_layer = params.get("from_layer", "F.Cu") - to_layer = params.get("to_layer", "B.Cu") - - if not position: - return { - "success": False, - "message": "Missing position", - "errorDetails": "position parameter is required", - } - - # Create via - via = pcbnew.PCB_VIA(self.board) - - # Set position - scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm - x_nm = int(position["x"] * scale) - y_nm = int(position["y"] * scale) - via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) - - # Set size and drill (default to board's current via settings) - design_settings = self.board.GetDesignSettings() - via.SetWidth(int(size * 1000000) if size else design_settings.GetCurrentViaSize()) - via.SetDrill(int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill()) - - # Set layers - from_id = self.board.GetLayerID(from_layer) - to_id = self.board.GetLayerID(to_layer) - if from_id < 0 or to_id < 0: - return { - "success": False, - "message": "Invalid layer", - "errorDetails": "Specified layers do not exist", - } - via.SetLayerPair(from_id, to_id) - - # Set net if provided - if net: - netinfo = self.board.GetNetInfo() - nets_map = netinfo.NetsByName() - if nets_map.has_key(net): - net_obj = nets_map[net] - via.SetNet(net_obj) - - # Add via to board - self.board.Add(via) - - return { - "success": True, - "message": "Added via", - "via": { - "position": { - "x": position["x"], - "y": position["y"], - "unit": position["unit"], - }, - "size": via.GetWidth(pcbnew.F_Cu) / 1000000, - "drill": via.GetDrill() / 1000000, - "from_layer": from_layer, - "to_layer": to_layer, - "net": net, - }, - } - - except Exception as e: - logger.error(f"Error adding via: {str(e)}") - return { - "success": False, - "message": "Failed to add via", - "errorDetails": str(e), - } - - def delete_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Delete a trace from the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - trace_uuid = params.get("traceUuid") - position = params.get("position") - net_name = params.get("net") - layer = params.get("layer") - include_vias = params.get("includeVias", False) - - if not trace_uuid and not position and not net_name: - return { - "success": False, - "message": "Missing parameters", - "errorDetails": "One of traceUuid, position, or net must be provided", - } - - # Delete by net name (bulk delete) - if net_name: - tracks_to_remove = [] - for track in list(self.board.Tracks()): - if track.GetNetname() != net_name: - continue - - # Skip vias if not requested - is_via = track.Type() == pcbnew.PCB_VIA_T - if is_via and not include_vias: - continue - - # Filter by layer if specified (only for non-vias) - if layer and not is_via: - layer_id = self.board.GetLayerID(layer) - if track.GetLayer() != layer_id: - continue - - tracks_to_remove.append(track) - - deleted_count = len(tracks_to_remove) - for track in tracks_to_remove: - self.board.Remove(track) - tracks_to_remove.clear() - self.board.SetModified() - - return { - "success": True, - "message": f"Deleted {deleted_count} traces on net '{net_name}'", - "deletedCount": deleted_count, - } - - # Find track by UUID - if trace_uuid: - track = None - for item in list(self.board.Tracks()): - if item.m_Uuid.AsString() == trace_uuid: - track = item - break - - if not track: - return { - "success": False, - "message": "Track not found", - "errorDetails": f"Could not find track with UUID: {trace_uuid}", - } - - self.board.Remove(track) - track = None - self.board.SetModified() - return {"success": True, "message": f"Deleted track: {trace_uuid}"} - - # No valid parameters provided - if not position: - return { - "success": False, - "message": "No valid search parameter provided", - "errorDetails": "Provide traceUuid, position, or net parameter", - } - - # Find track by position - if position: - scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm - x_nm = int(position["x"] * scale) - y_nm = int(position["y"] * scale) - point = pcbnew.VECTOR2I(x_nm, y_nm) - - # Find closest track - closest_track = None - min_distance = float("inf") - for track in list(self.board.Tracks()): - dist = self._point_to_track_distance(point, track) - if dist < min_distance: - min_distance = dist - closest_track = track - - if closest_track and min_distance < 1000000: # Within 1mm - self.board.Remove(closest_track) - closest_track = None - self.board.SetModified() - return { - "success": True, - "message": "Deleted track at specified position", - } - else: - return { - "success": False, - "message": "No track found", - "errorDetails": "No track found near specified position", - } - - except Exception as e: - logger.error(f"Error deleting trace: {str(e)}") - return { - "success": False, - "message": "Failed to delete trace", - "errorDetails": str(e), - } - return { - "success": False, - "message": "No action taken", - "errorDetails": "No matching trace found for given parameters", - } - - def get_nets_list(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get a list of all nets in the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - nets = [] - netinfo = self.board.GetNetInfo() - for net_code in range(netinfo.GetNetCount()): - net = netinfo.GetNetItem(net_code) - if net: - nets.append( - { - "name": net.GetNetname(), - "code": net.GetNetCode(), - "class": net.GetNetClassName(), - } - ) - - return {"success": True, "nets": nets} - - except Exception as e: - logger.error(f"Error getting nets list: {str(e)}") - return { - "success": False, - "message": "Failed to get nets list", - "errorDetails": str(e), - } - - def query_traces(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Query traces by net, layer, or bounding box""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - # Get filter parameters - net_name = params.get("net") - layer = params.get("layer") - bbox = params.get("boundingBox") # {x1, y1, x2, y2, unit} - include_vias = params.get("includeVias", False) - - scale = 1000000 # nm to mm conversion factor - traces = [] - vias = [] - - # Process tracks - for track in list(self.board.Tracks()): - try: - # Check if it's a via - is_via = track.Type() == pcbnew.PCB_VIA_T - - if is_via and not include_vias: - continue - - # Filter by net - if net_name and track.GetNetname() != net_name: - continue - - # Filter by layer (only for tracks, not vias) - if layer and not is_via: - layer_id = self.board.GetLayerID(layer) - if track.GetLayer() != layer_id: - continue - - # Filter by bounding box - if bbox: - bbox_unit = bbox.get("unit", "mm") - bbox_scale = scale if bbox_unit == "mm" else 25400000 - x1 = int(bbox.get("x1", 0) * bbox_scale) - y1 = int(bbox.get("y1", 0) * bbox_scale) - x2 = int(bbox.get("x2", 0) * bbox_scale) - y2 = int(bbox.get("y2", 0) * bbox_scale) - - if is_via: - pos = track.GetPosition() - if not (x1 <= pos.x <= x2 and y1 <= pos.y <= y2): - continue - else: - start = track.GetStart() - end = track.GetEnd() - # Check if either endpoint is within bbox - start_in = x1 <= start.x <= x2 and y1 <= start.y <= y2 - end_in = x1 <= end.x <= x2 and y1 <= end.y <= y2 - if not (start_in or end_in): - continue - - if is_via: - pos = track.GetPosition() - vias.append( - { - "uuid": track.m_Uuid.AsString(), - "position": { - "x": pos.x / scale, - "y": pos.y / scale, - "unit": "mm", - }, - "net": track.GetNetname(), - "netCode": track.GetNetCode(), - "diameter": track.GetWidth() / scale, - "drill": track.GetDrillValue() / scale, - } - ) - else: - start = track.GetStart() - end = track.GetEnd() - traces.append( - { - "uuid": track.m_Uuid.AsString(), - "net": track.GetNetname(), - "netCode": track.GetNetCode(), - "layer": self.board.GetLayerName(track.GetLayer()), - "width": track.GetWidth() / scale, - "start": { - "x": start.x / scale, - "y": start.y / scale, - "unit": "mm", - }, - "end": { - "x": end.x / scale, - "y": end.y / scale, - "unit": "mm", - }, - "length": track.GetLength() / scale, - } - ) - except Exception as track_err: - logger.warning(f"Skipping invalid track object: {track_err}") - continue - - result = {"success": True, "traceCount": len(traces), "traces": traces} - - if include_vias: - result["viaCount"] = len(vias) - result["vias"] = vias - - return result - - except Exception as e: - logger.error(f"Error querying traces: {str(e)}") - return { - "success": False, - "message": "Failed to query traces", - "errorDetails": str(e), - } - - def modify_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Modify properties of an existing trace - - Allows changing trace width, layer, and net assignment. - Find trace by UUID or position. - """ - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - # Identification parameters - trace_uuid = params.get("uuid") - position = params.get("position") # {x, y, unit} - - # Modification parameters - new_width = params.get("width") # in mm - new_layer = params.get("layer") - new_net = params.get("net") - - if not trace_uuid and not position: - return { - "success": False, - "message": "Missing trace identifier", - "errorDetails": "Provide either 'uuid' or 'position' to identify the trace", - } - - scale = 1000000 # nm to mm conversion - - # Find the track - track = None - - if trace_uuid: - for item in list(self.board.Tracks()): - if item.m_Uuid.AsString() == trace_uuid: - track = item - break - elif position: - pos_unit = position.get("unit", "mm") - pos_scale = scale if pos_unit == "mm" else 25400000 - x_nm = int(position["x"] * pos_scale) - y_nm = int(position["y"] * pos_scale) - point = pcbnew.VECTOR2I(x_nm, y_nm) - - # Find closest track - min_distance = float("inf") - for item in list(self.board.Tracks()): - dist = self._point_to_track_distance(point, item) - if dist < min_distance: - min_distance = dist - track = item - - # Only accept if within 1mm - if min_distance >= 1000000: - track = None - - if not track: - return { - "success": False, - "message": "Track not found", - "errorDetails": "Could not find track with specified identifier", - } - - # Check if it's a via (some modifications don't apply) - is_via = track.Type() == pcbnew.PCB_VIA_T - modifications = [] - - # Apply modifications - if new_width is not None: - width_nm = int(new_width * scale) - track.SetWidth(width_nm) - modifications.append(f"width={new_width}mm") - - if new_layer and not is_via: - layer_id = self.board.GetLayerID(new_layer) - if layer_id < 0: - return { - "success": False, - "message": "Invalid layer", - "errorDetails": f"Layer '{new_layer}' not found", - } - track.SetLayer(layer_id) - modifications.append(f"layer={new_layer}") - - if new_net: - netinfo = self.board.GetNetInfo() - net = netinfo.GetNetItem(new_net) - if not net: - return { - "success": False, - "message": "Invalid net", - "errorDetails": f"Net '{new_net}' not found", - } - track.SetNet(net) - modifications.append(f"net={new_net}") - - if not modifications: - return { - "success": False, - "message": "No modifications specified", - "errorDetails": "Provide at least one of: width, layer, net", - } - - return { - "success": True, - "message": f"Modified trace: {', '.join(modifications)}", - "uuid": track.m_Uuid.AsString(), - "modifications": modifications, - } - - except Exception as e: - logger.error(f"Error modifying trace: {str(e)}") - return { - "success": False, - "message": "Failed to modify trace", - "errorDetails": str(e), - } - - def copy_routing_pattern(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Copy routing pattern from source components to target components - - This enables routing replication between identical component groups. - The pattern is copied with a translation offset calculated from - the position difference between source and target components. - """ - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - source_refs = params.get("sourceRefs", []) # e.g., ["U1", "U2", "U3"] - target_refs = params.get("targetRefs", []) # e.g., ["U4", "U5", "U6"] - include_vias = params.get("includeVias", True) - trace_width = params.get("traceWidth") # Optional override - - if not source_refs or not target_refs: - return { - "success": False, - "message": "Missing component references", - "errorDetails": "Provide both 'sourceRefs' and 'targetRefs' arrays", - } - - if len(source_refs) != len(target_refs): - return { - "success": False, - "message": "Mismatched component counts", - "errorDetails": f"sourceRefs has {len(source_refs)} items, targetRefs has {len(target_refs)}", - } - - scale = 1000000 # nm to mm conversion - - # Get footprints - footprints = {fp.GetReference(): fp for fp in self.board.GetFootprints()} - - # Validate all references exist - for ref in source_refs + target_refs: - if ref not in footprints: - return { - "success": False, - "message": "Component not found", - "errorDetails": f"Component '{ref}' not found on board", - } - - # Calculate offset from first source to first target component - source_fp = footprints[source_refs[0]] - target_fp = footprints[target_refs[0]] - source_pos = source_fp.GetPosition() - target_pos = target_fp.GetPosition() - - offset_x = target_pos.x - source_pos.x - offset_y = target_pos.y - source_pos.y - - # Build mapping from source refs to target refs - ref_mapping = dict(zip(source_refs, target_refs)) - - # Collect all nets connected to source components - source_nets = set() - source_pad_positions = [] # (x, y) in nm for geometric fallback - for ref in source_refs: - fp = footprints[ref] - for pad in fp.Pads(): - net_name = pad.GetNetname() - if net_name and net_name != "": - source_nets.add(net_name) - pos = pad.GetPosition() - source_pad_positions.append((pos.x, pos.y)) - - # Build bounding box around source pads (with 5mm tolerance in nm) - TOLERANCE_NM = int(5 * scale) - if source_pad_positions: - xs = [p[0] for p in source_pad_positions] - ys = [p[1] for p in source_pad_positions] - bbox_x1 = min(xs) - TOLERANCE_NM - bbox_x2 = max(xs) + TOLERANCE_NM - bbox_y1 = min(ys) - TOLERANCE_NM - bbox_y2 = max(ys) + TOLERANCE_NM - else: - # Fall back to component position ± 25mm - sp = source_fp.GetPosition() - bbox_x1 = sp.x - int(25 * scale) - bbox_x2 = sp.x + int(25 * scale) - bbox_y1 = sp.y - int(25 * scale) - bbox_y2 = sp.y + int(25 * scale) - - def point_in_bbox(px: int, py: int) -> bool: - return bbox_x1 <= px <= bbox_x2 and bbox_y1 <= py <= bbox_y2 - - # Collect traces: by net name (if available) OR by geometric proximity - use_net_filter = len(source_nets) > 0 - traces_to_copy = [] - vias_to_copy = [] - - for track in list(self.board.Tracks()): - is_via = track.Type() == pcbnew.PCB_VIA_T - - if use_net_filter: - # Primary: net-based filter - if track.GetNetname() not in source_nets: - continue - else: - # Fallback: geometric filter – trace start OR end inside source bbox - if is_via: - pos = track.GetPosition() - if not point_in_bbox(pos.x, pos.y): - continue - else: - s = track.GetStart() - e = track.GetEnd() - if not (point_in_bbox(s.x, s.y) or point_in_bbox(e.x, e.y)): - continue - - if is_via: - if include_vias: - vias_to_copy.append(track) - else: - traces_to_copy.append(track) - - 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}" - ) - - # Create new traces with offset - created_traces = 0 - created_vias = 0 - - for track in traces_to_copy: - start = track.GetStart() - end = track.GetEnd() - - # Create new track - new_track = pcbnew.PCB_TRACK(self.board) - new_track.SetStart(pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y)) - new_track.SetEnd(pcbnew.VECTOR2I(end.x + offset_x, end.y + offset_y)) - new_track.SetLayer(track.GetLayer()) - - # Set width (use override or original) - if trace_width: - new_track.SetWidth(int(trace_width * scale)) - else: - new_track.SetWidth(track.GetWidth()) - - # Try to find corresponding target net - # This is a simplification - more sophisticated mapping would be needed - # for complex designs - self.board.Add(new_track) - created_traces += 1 - - for via in vias_to_copy: - pos = via.GetPosition() - - # Create new via - new_via = pcbnew.PCB_VIA(self.board) - new_via.SetPosition(pcbnew.VECTOR2I(pos.x + offset_x, pos.y + offset_y)) - new_via.SetWidth(via.GetWidth(pcbnew.F_Cu)) - new_via.SetDrill(via.GetDrillValue()) - new_via.SetViaType(via.GetViaType()) - - self.board.Add(new_via) - created_vias += 1 - - result = { - "success": True, - "message": f"Copied routing pattern: {created_traces} traces, {created_vias} vias", - "filterMethod": filter_method, - "offset": {"x": offset_x / scale, "y": offset_y / scale, "unit": "mm"}, - "createdTraces": created_traces, - "createdVias": created_vias, - "sourceComponents": source_refs, - "targetComponents": target_refs, - } - - return result - - except Exception as e: - logger.error(f"Error copying routing pattern: {str(e)}") - return { - "success": False, - "message": "Failed to copy routing pattern", - "errorDetails": str(e), - } - - def create_netclass(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Create a new net class with specified properties""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - name = params.get("name") - clearance = params.get("clearance") - track_width = params.get("trackWidth") - via_diameter = params.get("viaDiameter") - via_drill = params.get("viaDrill") - uvia_diameter = params.get("uviaDiameter") - uvia_drill = params.get("uviaDrill") - diff_pair_width = params.get("diffPairWidth") - diff_pair_gap = params.get("diffPairGap") - nets = params.get("nets", []) - - if not name: - return { - "success": False, - "message": "Missing netclass name", - "errorDetails": "name parameter is required", - } - - # Get net classes - net_classes = self.board.GetNetClasses() - - # Create new net class if it doesn't exist - if not net_classes.Find(name): - netclass = pcbnew.NETCLASS(name) - net_classes.Add(netclass) - else: - netclass = net_classes.Find(name) - - # Set properties - scale = 1000000 # mm to nm - if clearance is not None: - netclass.SetClearance(int(clearance * scale)) - if track_width is not None: - netclass.SetTrackWidth(int(track_width * scale)) - if via_diameter is not None: - netclass.SetViaDiameter(int(via_diameter * scale)) - if via_drill is not None: - netclass.SetViaDrill(int(via_drill * scale)) - if uvia_diameter is not None: - netclass.SetMicroViaDiameter(int(uvia_diameter * scale)) - if uvia_drill is not None: - netclass.SetMicroViaDrill(int(uvia_drill * scale)) - if diff_pair_width is not None: - netclass.SetDiffPairWidth(int(diff_pair_width * scale)) - if diff_pair_gap is not None: - netclass.SetDiffPairGap(int(diff_pair_gap * scale)) - - # Add nets to net class - netinfo = self.board.GetNetInfo() - nets_map = netinfo.NetsByName() - for net_name in nets: - if nets_map.has_key(net_name): - net = nets_map[net_name] - net.SetClass(netclass) - - return { - "success": True, - "message": f"Created net class: {name}", - "netClass": { - "name": name, - "clearance": netclass.GetClearance() / scale, - "trackWidth": netclass.GetTrackWidth() / scale, - "viaDiameter": netclass.GetViaDiameter() / scale, - "viaDrill": netclass.GetViaDrill() / scale, - "uviaDiameter": netclass.GetMicroViaDiameter() / scale, - "uviaDrill": netclass.GetMicroViaDrill() / scale, - "diffPairWidth": netclass.GetDiffPairWidth() / scale, - "diffPairGap": netclass.GetDiffPairGap() / scale, - "nets": nets, - }, - } - - except Exception as e: - logger.error(f"Error creating net class: {str(e)}") - return { - "success": False, - "message": "Failed to create net class", - "errorDetails": str(e), - } - - def add_copper_pour(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Add a copper pour (zone) to the PCB""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - layer = params.get("layer", "F.Cu") - net = params.get("net") - clearance = params.get("clearance") - min_width = params.get("minWidth", 0.2) - 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: - 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 - - # Detect corner radius from Edge.Cuts arcs so the zone rectangle - # stays inside the rounded board corners (avoids zone visually - # extending outside Edge.Cuts before refill) - corner_radius = 0.0 - edge_layer_id = self.board.GetLayerID("Edge.Cuts") - for item in self.board.GetDrawings(): - if item.GetLayer() == edge_layer_id and item.GetClass() == "PCB_ARC": - r = item.GetRadius() / scale - if r > corner_radius: - corner_radius = r - # Inset the zone rectangle by the corner radius so its corners - # lie on the straight portions of the board edge. - inset = corner_radius - points = [ - {"x": x1 + inset, "y": y1 + inset}, - {"x": x2 - inset, "y": y1 + inset}, - {"x": x2 - inset, "y": y2 - inset}, - {"x": x1 + inset, "y": y2 - inset}, - ] - 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) - if layer_id < 0: - return { - "success": False, - "message": "Invalid layer", - "errorDetails": f"Layer '{layer}' does not exist", - } - - # Create zone - zone = pcbnew.ZONE(self.board) - zone.SetLayer(layer_id) - - # Set net if provided - if net: - netinfo = self.board.GetNetInfo() - nets_map = netinfo.NetsByName() - if nets_map.has_key(net): - net_obj = nets_map[net] - zone.SetNet(net_obj) - - # Set zone properties - scale = 1000000 # mm to nm - zone.SetAssignedPriority(priority) - - if clearance is not None: - zone.SetLocalClearance(int(clearance * scale)) - - zone.SetMinThickness(int(min_width * scale)) - - # Set fill type - if fill_type == "hatched": - zone.SetFillMode(pcbnew.ZONE_FILL_MODE_HATCH_PATTERN) - else: - zone.SetFillMode(pcbnew.ZONE_FILL_MODE_POLYGONS) - - # Create outline - outline = zone.Outline() - outline.NewOutline() # Create a new outline contour first - - # Add points to outline - for point in points: - scale = 1000000 if point.get("unit", "mm") == "mm" else 25400000 - x_nm = int(point["x"] * scale) - y_nm = int(point["y"] * scale) - outline.Append(pcbnew.VECTOR2I(x_nm, y_nm)) # Add point to outline - - # Add zone to board - self.board.Add(zone) - - # Fill zone - # Note: Zone filling can cause issues with SWIG API - # Comment out for now - zones will be filled when board is saved/opened in KiCAD - # filler = pcbnew.ZONE_FILLER(self.board) - # filler.Fill(self.board.Zones()) - - return { - "success": True, - "message": "Added copper pour", - "pour": { - "layer": layer, - "net": net, - "clearance": clearance, - "minWidth": min_width, - "priority": priority, - "fillType": fill_type, - "pointCount": len(points), - }, - } - - except Exception as e: - logger.error(f"Error adding copper pour: {str(e)}") - return { - "success": False, - "message": "Failed to add copper pour", - "errorDetails": str(e), - } - - def route_differential_pair(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Route a differential pair between two sets of points or pads""" - try: - if not self.board: - return { - "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", - } - - start_pos = params.get("startPos") - end_pos = params.get("endPos") - net_pos = params.get("netPos") - net_neg = params.get("netNeg") - layer = params.get("layer", "F.Cu") - width = params.get("width") - gap = params.get("gap") - - if not start_pos or not end_pos or not net_pos or not net_neg: - return { - "success": False, - "message": "Missing parameters", - "errorDetails": "startPos, endPos, netPos, and netNeg are required", - } - - # Get layer ID - layer_id = self.board.GetLayerID(layer) - if layer_id < 0: - return { - "success": False, - "message": "Invalid layer", - "errorDetails": f"Layer '{layer}' does not exist", - } - - # Get nets - netinfo = self.board.GetNetInfo() - nets_map = netinfo.NetsByName() - - net_pos_obj = nets_map[net_pos] if nets_map.has_key(net_pos) else None - net_neg_obj = nets_map[net_neg] if nets_map.has_key(net_neg) else None - - if not net_pos_obj or not net_neg_obj: - return { - "success": False, - "message": "Nets not found", - "errorDetails": "One or both nets specified for the differential pair do not exist", - } - - # Get start and end points - start_point = self._get_point(start_pos) - end_point = self._get_point(end_pos) - - # Calculate offset vectors for the two traces - # First, get the direction vector from start to end - dx = end_point.x - start_point.x - dy = end_point.y - start_point.y - length = math.sqrt(dx * dx + dy * dy) - - if length <= 0: - return { - "success": False, - "message": "Invalid points", - "errorDetails": "Start and end points must be different", - } - - # Normalize direction vector - dx /= length - dy /= length - - # Get perpendicular vector - px = -dy - py = dx - - # Set default gap if not provided - if gap is None: - gap = 0.2 # mm - - # Convert to nm - gap_nm = int(gap * 1000000) - - # Calculate offsets - offset_x = int(px * gap_nm / 2) - offset_y = int(py * gap_nm / 2) - - # Create positive and negative trace points - pos_start = pcbnew.VECTOR2I( - int(start_point.x + offset_x), int(start_point.y + offset_y) - ) - pos_end = pcbnew.VECTOR2I(int(end_point.x + offset_x), int(end_point.y + offset_y)) - neg_start = pcbnew.VECTOR2I( - int(start_point.x - offset_x), int(start_point.y - offset_y) - ) - neg_end = pcbnew.VECTOR2I(int(end_point.x - offset_x), int(end_point.y - offset_y)) - - # Create positive trace - pos_track = pcbnew.PCB_TRACK(self.board) - pos_track.SetStart(pos_start) - pos_track.SetEnd(pos_end) - pos_track.SetLayer(layer_id) - pos_track.SetNet(net_pos_obj) - - # Create negative trace - neg_track = pcbnew.PCB_TRACK(self.board) - neg_track.SetStart(neg_start) - neg_track.SetEnd(neg_end) - neg_track.SetLayer(layer_id) - neg_track.SetNet(net_neg_obj) - - # Set width - if width: - trace_width_nm = int(width * 1000000) - pos_track.SetWidth(trace_width_nm) - neg_track.SetWidth(trace_width_nm) - else: - # Get default width from design rules or net class - trace_width = self.board.GetDesignSettings().GetCurrentTrackWidth() - pos_track.SetWidth(trace_width) - neg_track.SetWidth(trace_width) - - # Add tracks to board - self.board.Add(pos_track) - self.board.Add(neg_track) - - return { - "success": True, - "message": "Added differential pair traces", - "diffPair": { - "posNet": net_pos, - "negNet": net_neg, - "layer": layer, - "width": pos_track.GetWidth() / 1000000, - "gap": gap, - "length": length / 1000000, - }, - } - - except Exception as e: - logger.error(f"Error routing differential pair: {str(e)}") - return { - "success": False, - "message": "Failed to route differential pair", - "errorDetails": str(e), - } - - def _get_point(self, point_spec: Dict[str, Any]) -> pcbnew.VECTOR2I: - """Convert point specification to KiCAD point""" - if "x" in point_spec and "y" in point_spec: - scale = 1000000 if point_spec.get("unit", "mm") == "mm" else 25400000 - x_nm = int(point_spec["x"] * scale) - y_nm = int(point_spec["y"] * scale) - return pcbnew.VECTOR2I(x_nm, y_nm) - elif "pad" in point_spec and "componentRef" in point_spec: - module = self.board.FindFootprintByReference(point_spec["componentRef"]) - if module: - pad = module.FindPadByName(point_spec["pad"]) - if pad: - return pad.GetPosition() - raise ValueError("Invalid point specification") - - def _point_to_track_distance(self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK) -> float: - """Calculate distance from point to track segment""" - start = track.GetStart() - end = track.GetEnd() - - # Vector from start to end - v = pcbnew.VECTOR2I(end.x - start.x, end.y - start.y) - # Vector from start to point - w = pcbnew.VECTOR2I(point.x - start.x, point.y - start.y) - - # Length of track squared - c1 = v.x * v.x + v.y * v.y - if c1 == 0: - return self._point_distance(point, start) - - # Projection coefficient - c2 = float(w.x * v.x + w.y * v.y) / c1 - - if c2 < 0: - return self._point_distance(point, start) - elif c2 > 1: - return self._point_distance(point, end) - - # Point on line - proj = pcbnew.VECTOR2I(int(start.x + c2 * v.x), int(start.y + c2 * v.y)) - return self._point_distance(point, proj) - - def _point_distance(self, p1: pcbnew.VECTOR2I, p2: pcbnew.VECTOR2I) -> float: - """Calculate distance between two points""" - dx = p1.x - p2.x - dy = p1.y - p2.y - return (dx * dx + dy * dy) ** 0.5 +""" +Routing-related command implementations for KiCAD interface +""" + +import logging +import math +import os +from typing import Any, Dict, List, Optional, Tuple + +import pcbnew + +logger = logging.getLogger("kicad_interface") + + +class RoutingCommands: + """Handles routing-related KiCAD operations""" + + def __init__(self, board: Optional[pcbnew.BOARD] = None): + """Initialize with optional board instance""" + self.board = board + + def add_net(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a new net to the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + name = params.get("name") + net_class = params.get("class") + + if not name: + return { + "success": False, + "message": "Missing net name", + "errorDetails": "name parameter is required", + } + + # Create new net + netinfo = self.board.GetNetInfo() + nets_map = netinfo.NetsByName() + if nets_map.has_key(name): + net = nets_map[name] + else: + net = pcbnew.NETINFO_ITEM(self.board, name) + self.board.Add(net) + + # Set net class if provided + if net_class: + net_classes = self.board.GetNetClasses() + if net_classes.Find(net_class): + net.SetClass(net_classes.Find(net_class)) + + return { + "success": True, + "message": f"Added net: {name}", + "net": { + "name": name, + "class": net_class if net_class else "Default", + "netcode": net.GetNetCode(), + }, + } + + except Exception as e: + logger.error(f"Error adding net: {str(e)}") + return { + "success": False, + "message": "Failed to add net", + "errorDetails": str(e), + } + + def route_pad_to_pad(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Route a trace directly from one component pad to another. + + Looks up pad positions automatically, then creates a trace. + Convenience wrapper around route_trace that eliminates the need + for separate get_pad_position calls. + """ + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + from_ref = params.get("fromRef") + from_pad = str(params.get("fromPad", "")) + to_ref = params.get("toRef") + to_pad = str(params.get("toPad", "")) + layer = params.get("layer", "F.Cu") + width = params.get("width") + net = params.get("net") # optional override + + if not from_ref or not from_pad or not to_ref or not to_pad: + return { + "success": False, + "message": "Missing parameters", + "errorDetails": "fromRef, fromPad, toRef, toPad are all required", + } + + scale = 1000000 # nm to mm + + # Find pads + footprints = {fp.GetReference(): fp for fp in self.board.GetFootprints()} + + for ref in [from_ref, to_ref]: + if ref not in footprints: + return { + "success": False, + "message": f"Component not found: {ref}", + "errorDetails": f"'{ref}' does not exist on the board", + } + + def find_pad(ref: str, pad_num: str) -> Any: + fp = footprints[ref] + for pad in fp.Pads(): + if pad.GetNumber() == pad_num: + return pad + return None + + start_pad = find_pad(from_ref, from_pad) + end_pad = find_pad(to_ref, to_pad) + + if not start_pad: + return { + "success": False, + "message": f"Pad not found: {from_ref} pad {from_pad}", + "errorDetails": f"Check pad number for {from_ref}", + } + if not end_pad: + return { + "success": False, + "message": f"Pad not found: {to_ref} pad {to_pad}", + "errorDetails": f"Check pad number for {to_ref}", + } + + start_pos = start_pad.GetPosition() + end_pos = end_pad.GetPosition() + + # Use net from start pad if not overridden + if not net: + net = start_pad.GetNetname() or end_pad.GetNetname() or "" + + # Detect if pads are on different copper layers → need via. + # SMD pad.GetLayer() reports F.Cu even on flipped B.Cu footprints in + # KiCAD 9 SWIG. Use footprint.GetLayer() instead — it always reflects + # the actual placed layer after Flip(). + fp_start = footprints[from_ref] + fp_end = footprints[to_ref] + start_layer = self.board.GetLayerName(fp_start.GetLayer()) + end_layer = self.board.GetLayerName(fp_end.GetLayer()) + copper_layers = {"F.Cu", "B.Cu"} + needs_via = ( + start_layer in copper_layers + and end_layer in copper_layers + and start_layer != end_layer + ) + + if needs_via: + # Place via directly below the start pad (same X). + # Using the geometric midpoint X causes all vias to stack at + # the same X when pads are back-to-back mirrored (e.g. J1/J2 + # on F.Cu/B.Cu): midpoint is always the board center. + via_x = start_pos.x / scale + via_y = (start_pos.y + end_pos.y) / 2 / scale + + # Trace on start layer: start_pad → via + r1 = self.route_trace( + { + "start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"}, + "end": {"x": via_x, "y": via_y, "unit": "mm"}, + "layer": start_layer, + "width": width, + "net": net, + } + ) + # Via connecting both layers + self.add_via( + { + "position": {"x": via_x, "y": via_y, "unit": "mm"}, + "net": net, + "from_layer": start_layer, + "to_layer": end_layer, + } + ) + # Trace on end layer: via → end_pad + r2 = self.route_trace( + { + "start": {"x": via_x, "y": via_y, "unit": "mm"}, + "end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"}, + "layer": end_layer, + "width": width, + "net": net, + } + ) + success = r1.get("success") and r2.get("success") + result = { + "success": success, + "message": f"Routed {from_ref}.{from_pad} → via → {to_ref}.{to_pad} (net: {net}, via at {via_x:.2f},{via_y:.2f})", + "via_added": True, + "via_position": {"x": via_x, "y": via_y}, + } + else: + # Same layer — direct 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 if layer else start_layer, + "width": width, + "net": net, + } + ) + + if result.get("success"): + 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 + + except Exception as e: + logger.error(f"Error in route_pad_to_pad: {str(e)}") + return { + "success": False, + "message": "Failed to route pad to pad", + "errorDetails": str(e), + } + + def route_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Route a trace between two points or pads""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + start = params.get("start") + end = params.get("end") + layer = params.get("layer", "F.Cu") + width = params.get("width") + net = params.get("net") + via = params.get("via", False) + + if not start or not end: + return { + "success": False, + "message": "Missing parameters", + "errorDetails": "start and end points are required", + } + + # Get layer ID + layer_id = self.board.GetLayerID(layer) + if layer_id < 0: + return { + "success": False, + "message": "Invalid layer", + "errorDetails": f"Layer '{layer}' does not exist", + } + + # Get start point + start_point = self._get_point(start) + end_point = self._get_point(end) + + # Create track segment + track = pcbnew.PCB_TRACK(self.board) + track.SetStart(start_point) + track.SetEnd(end_point) + track.SetLayer(layer_id) + + # Set width (default to board's current track width) + if width: + track.SetWidth(int(width * 1000000)) # Convert mm to nm + else: + track.SetWidth(self.board.GetDesignSettings().GetCurrentTrackWidth()) + + # Set net if provided + if net: + netinfo = self.board.GetNetInfo() + nets_map = netinfo.NetsByName() + if nets_map.has_key(net): + net_obj = nets_map[net] + track.SetNet(net_obj) + + # Add track to board + self.board.Add(track) + + # Add via if requested and net is specified + if via and net: + via_point = end_point + self.add_via( + { + "position": { + "x": via_point.x / 1000000, + "y": via_point.y / 1000000, + "unit": "mm", + }, + "net": net, + } + ) + + return { + "success": True, + "message": "Added trace", + "trace": { + "start": { + "x": start_point.x / 1000000, + "y": start_point.y / 1000000, + "unit": "mm", + }, + "end": { + "x": end_point.x / 1000000, + "y": end_point.y / 1000000, + "unit": "mm", + }, + "layer": layer, + "width": track.GetWidth() / 1000000, + "net": net, + }, + } + + except Exception as e: + logger.error(f"Error routing trace: {str(e)}") + return { + "success": False, + "message": "Failed to route trace", + "errorDetails": str(e), + } + + def add_via(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a via at the specified location""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + position = params.get("position") + size = params.get("size") + drill = params.get("drill") + net = params.get("net") + from_layer = params.get("from_layer", "F.Cu") + to_layer = params.get("to_layer", "B.Cu") + + if not position: + return { + "success": False, + "message": "Missing position", + "errorDetails": "position parameter is required", + } + + # Create via + via = pcbnew.PCB_VIA(self.board) + + # Set position + scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm + x_nm = int(position["x"] * scale) + y_nm = int(position["y"] * scale) + via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) + + # Set size and drill (default to board's current via settings) + design_settings = self.board.GetDesignSettings() + via.SetWidth(int(size * 1000000) if size else design_settings.GetCurrentViaSize()) + via.SetDrill(int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill()) + + # Set layers + from_id = self.board.GetLayerID(from_layer) + to_id = self.board.GetLayerID(to_layer) + if from_id < 0 or to_id < 0: + return { + "success": False, + "message": "Invalid layer", + "errorDetails": "Specified layers do not exist", + } + via.SetLayerPair(from_id, to_id) + + # Set net if provided + if net: + netinfo = self.board.GetNetInfo() + nets_map = netinfo.NetsByName() + if nets_map.has_key(net): + net_obj = nets_map[net] + via.SetNet(net_obj) + + # Add via to board + self.board.Add(via) + + return { + "success": True, + "message": "Added via", + "via": { + "position": { + "x": position["x"], + "y": position["y"], + "unit": position["unit"], + }, + "size": via.GetWidth(pcbnew.F_Cu) / 1000000, + "drill": via.GetDrill() / 1000000, + "from_layer": from_layer, + "to_layer": to_layer, + "net": net, + }, + } + + except Exception as e: + logger.error(f"Error adding via: {str(e)}") + return { + "success": False, + "message": "Failed to add via", + "errorDetails": str(e), + } + + def delete_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Delete a trace from the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + trace_uuid = params.get("traceUuid") + position = params.get("position") + net_name = params.get("net") + layer = params.get("layer") + include_vias = params.get("includeVias", False) + + if not trace_uuid and not position and not net_name: + return { + "success": False, + "message": "Missing parameters", + "errorDetails": "One of traceUuid, position, or net must be provided", + } + + # Delete by net name (bulk delete) + if net_name: + tracks_to_remove = [] + for track in list(self.board.Tracks()): + if track.GetNetname() != net_name: + continue + + # Skip vias if not requested + is_via = track.Type() == pcbnew.PCB_VIA_T + if is_via and not include_vias: + continue + + # Filter by layer if specified (only for non-vias) + if layer and not is_via: + layer_id = self.board.GetLayerID(layer) + if track.GetLayer() != layer_id: + continue + + tracks_to_remove.append(track) + + deleted_count = len(tracks_to_remove) + for track in tracks_to_remove: + self.board.Remove(track) + tracks_to_remove.clear() + self.board.SetModified() + + return { + "success": True, + "message": f"Deleted {deleted_count} traces on net '{net_name}'", + "deletedCount": deleted_count, + } + + # Find track by UUID + if trace_uuid: + track = None + for item in list(self.board.Tracks()): + if item.m_Uuid.AsString() == trace_uuid: + track = item + break + + if not track: + return { + "success": False, + "message": "Track not found", + "errorDetails": f"Could not find track with UUID: {trace_uuid}", + } + + self.board.Remove(track) + track = None + self.board.SetModified() + return {"success": True, "message": f"Deleted track: {trace_uuid}"} + + # No valid parameters provided + if not position: + return { + "success": False, + "message": "No valid search parameter provided", + "errorDetails": "Provide traceUuid, position, or net parameter", + } + + # Find track by position + if position: + scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm + x_nm = int(position["x"] * scale) + y_nm = int(position["y"] * scale) + point = pcbnew.VECTOR2I(x_nm, y_nm) + + # Find closest track + closest_track = None + min_distance = float("inf") + for track in list(self.board.Tracks()): + dist = self._point_to_track_distance(point, track) + if dist < min_distance: + min_distance = dist + closest_track = track + + if closest_track and min_distance < 1000000: # Within 1mm + self.board.Remove(closest_track) + closest_track = None + self.board.SetModified() + return { + "success": True, + "message": "Deleted track at specified position", + } + else: + return { + "success": False, + "message": "No track found", + "errorDetails": "No track found near specified position", + } + + except Exception as e: + logger.error(f"Error deleting trace: {str(e)}") + return { + "success": False, + "message": "Failed to delete trace", + "errorDetails": str(e), + } + return { + "success": False, + "message": "No action taken", + "errorDetails": "No matching trace found for given parameters", + } + + def get_nets_list(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Get a list of all nets in the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + nets = [] + netinfo = self.board.GetNetInfo() + for net_code in range(netinfo.GetNetCount()): + net = netinfo.GetNetItem(net_code) + if net: + nets.append( + { + "name": net.GetNetname(), + "code": net.GetNetCode(), + "class": net.GetNetClassName(), + } + ) + + return {"success": True, "nets": nets} + + except Exception as e: + logger.error(f"Error getting nets list: {str(e)}") + return { + "success": False, + "message": "Failed to get nets list", + "errorDetails": str(e), + } + + def query_traces(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Query traces by net, layer, or bounding box""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + # Get filter parameters + net_name = params.get("net") + layer = params.get("layer") + bbox = params.get("boundingBox") # {x1, y1, x2, y2, unit} + include_vias = params.get("includeVias", False) + + scale = 1000000 # nm to mm conversion factor + traces = [] + vias = [] + + # Process tracks + for track in list(self.board.Tracks()): + try: + # Check if it's a via + is_via = track.Type() == pcbnew.PCB_VIA_T + + if is_via and not include_vias: + continue + + # Filter by net + if net_name and track.GetNetname() != net_name: + continue + + # Filter by layer (only for tracks, not vias) + if layer and not is_via: + layer_id = self.board.GetLayerID(layer) + if track.GetLayer() != layer_id: + continue + + # Filter by bounding box + if bbox: + bbox_unit = bbox.get("unit", "mm") + bbox_scale = scale if bbox_unit == "mm" else 25400000 + x1 = int(bbox.get("x1", 0) * bbox_scale) + y1 = int(bbox.get("y1", 0) * bbox_scale) + x2 = int(bbox.get("x2", 0) * bbox_scale) + y2 = int(bbox.get("y2", 0) * bbox_scale) + + if is_via: + pos = track.GetPosition() + if not (x1 <= pos.x <= x2 and y1 <= pos.y <= y2): + continue + else: + start = track.GetStart() + end = track.GetEnd() + # Check if either endpoint is within bbox + start_in = x1 <= start.x <= x2 and y1 <= start.y <= y2 + end_in = x1 <= end.x <= x2 and y1 <= end.y <= y2 + if not (start_in or end_in): + continue + + if is_via: + pos = track.GetPosition() + vias.append( + { + "uuid": track.m_Uuid.AsString(), + "position": { + "x": pos.x / scale, + "y": pos.y / scale, + "unit": "mm", + }, + "net": track.GetNetname(), + "netCode": track.GetNetCode(), + "diameter": track.GetWidth() / scale, + "drill": track.GetDrillValue() / scale, + } + ) + else: + start = track.GetStart() + end = track.GetEnd() + traces.append( + { + "uuid": track.m_Uuid.AsString(), + "net": track.GetNetname(), + "netCode": track.GetNetCode(), + "layer": self.board.GetLayerName(track.GetLayer()), + "width": track.GetWidth() / scale, + "start": { + "x": start.x / scale, + "y": start.y / scale, + "unit": "mm", + }, + "end": { + "x": end.x / scale, + "y": end.y / scale, + "unit": "mm", + }, + "length": track.GetLength() / scale, + } + ) + except Exception as track_err: + logger.warning(f"Skipping invalid track object: {track_err}") + continue + + result = {"success": True, "traceCount": len(traces), "traces": traces} + + if include_vias: + result["viaCount"] = len(vias) + result["vias"] = vias + + return result + + except Exception as e: + logger.error(f"Error querying traces: {str(e)}") + return { + "success": False, + "message": "Failed to query traces", + "errorDetails": str(e), + } + + def modify_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Modify properties of an existing trace + + Allows changing trace width, layer, and net assignment. + Find trace by UUID or position. + """ + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + # Identification parameters + trace_uuid = params.get("uuid") + position = params.get("position") # {x, y, unit} + + # Modification parameters + new_width = params.get("width") # in mm + new_layer = params.get("layer") + new_net = params.get("net") + + if not trace_uuid and not position: + return { + "success": False, + "message": "Missing trace identifier", + "errorDetails": "Provide either 'uuid' or 'position' to identify the trace", + } + + scale = 1000000 # nm to mm conversion + + # Find the track + track = None + + if trace_uuid: + for item in list(self.board.Tracks()): + if item.m_Uuid.AsString() == trace_uuid: + track = item + break + elif position: + pos_unit = position.get("unit", "mm") + pos_scale = scale if pos_unit == "mm" else 25400000 + x_nm = int(position["x"] * pos_scale) + y_nm = int(position["y"] * pos_scale) + point = pcbnew.VECTOR2I(x_nm, y_nm) + + # Find closest track + min_distance = float("inf") + for item in list(self.board.Tracks()): + dist = self._point_to_track_distance(point, item) + if dist < min_distance: + min_distance = dist + track = item + + # Only accept if within 1mm + if min_distance >= 1000000: + track = None + + if not track: + return { + "success": False, + "message": "Track not found", + "errorDetails": "Could not find track with specified identifier", + } + + # Check if it's a via (some modifications don't apply) + is_via = track.Type() == pcbnew.PCB_VIA_T + modifications = [] + + # Apply modifications + if new_width is not None: + width_nm = int(new_width * scale) + track.SetWidth(width_nm) + modifications.append(f"width={new_width}mm") + + if new_layer and not is_via: + layer_id = self.board.GetLayerID(new_layer) + if layer_id < 0: + return { + "success": False, + "message": "Invalid layer", + "errorDetails": f"Layer '{new_layer}' not found", + } + track.SetLayer(layer_id) + modifications.append(f"layer={new_layer}") + + if new_net: + netinfo = self.board.GetNetInfo() + net = netinfo.GetNetItem(new_net) + if not net: + return { + "success": False, + "message": "Invalid net", + "errorDetails": f"Net '{new_net}' not found", + } + track.SetNet(net) + modifications.append(f"net={new_net}") + + if not modifications: + return { + "success": False, + "message": "No modifications specified", + "errorDetails": "Provide at least one of: width, layer, net", + } + + return { + "success": True, + "message": f"Modified trace: {', '.join(modifications)}", + "uuid": track.m_Uuid.AsString(), + "modifications": modifications, + } + + except Exception as e: + logger.error(f"Error modifying trace: {str(e)}") + return { + "success": False, + "message": "Failed to modify trace", + "errorDetails": str(e), + } + + def copy_routing_pattern(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Copy routing pattern from source components to target components + + This enables routing replication between identical component groups. + The pattern is copied with a translation offset calculated from + the position difference between source and target components. + """ + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + source_refs = params.get("sourceRefs", []) # e.g., ["U1", "U2", "U3"] + target_refs = params.get("targetRefs", []) # e.g., ["U4", "U5", "U6"] + include_vias = params.get("includeVias", True) + trace_width = params.get("traceWidth") # Optional override + + if not source_refs or not target_refs: + return { + "success": False, + "message": "Missing component references", + "errorDetails": "Provide both 'sourceRefs' and 'targetRefs' arrays", + } + + if len(source_refs) != len(target_refs): + return { + "success": False, + "message": "Mismatched component counts", + "errorDetails": f"sourceRefs has {len(source_refs)} items, targetRefs has {len(target_refs)}", + } + + scale = 1000000 # nm to mm conversion + + # Get footprints + footprints = {fp.GetReference(): fp for fp in self.board.GetFootprints()} + + # Validate all references exist + for ref in source_refs + target_refs: + if ref not in footprints: + return { + "success": False, + "message": "Component not found", + "errorDetails": f"Component '{ref}' not found on board", + } + + # Calculate offset from first source to first target component + source_fp = footprints[source_refs[0]] + target_fp = footprints[target_refs[0]] + source_pos = source_fp.GetPosition() + target_pos = target_fp.GetPosition() + + offset_x = target_pos.x - source_pos.x + offset_y = target_pos.y - source_pos.y + + # Build mapping from source refs to target refs + ref_mapping = dict(zip(source_refs, target_refs)) + + # Collect all nets connected to source components + source_nets = set() + source_pad_positions = [] # (x, y) in nm for geometric fallback + for ref in source_refs: + fp = footprints[ref] + for pad in fp.Pads(): + net_name = pad.GetNetname() + if net_name and net_name != "": + source_nets.add(net_name) + pos = pad.GetPosition() + source_pad_positions.append((pos.x, pos.y)) + + # Build bounding box around source pads (with 5mm tolerance in nm) + TOLERANCE_NM = int(5 * scale) + if source_pad_positions: + xs = [p[0] for p in source_pad_positions] + ys = [p[1] for p in source_pad_positions] + bbox_x1 = min(xs) - TOLERANCE_NM + bbox_x2 = max(xs) + TOLERANCE_NM + bbox_y1 = min(ys) - TOLERANCE_NM + bbox_y2 = max(ys) + TOLERANCE_NM + else: + # Fall back to component position ± 25mm + sp = source_fp.GetPosition() + bbox_x1 = sp.x - int(25 * scale) + bbox_x2 = sp.x + int(25 * scale) + bbox_y1 = sp.y - int(25 * scale) + bbox_y2 = sp.y + int(25 * scale) + + def point_in_bbox(px: int, py: int) -> bool: + return bbox_x1 <= px <= bbox_x2 and bbox_y1 <= py <= bbox_y2 + + # Collect traces: by net name (if available) OR by geometric proximity + use_net_filter = len(source_nets) > 0 + traces_to_copy = [] + vias_to_copy = [] + + for track in list(self.board.Tracks()): + is_via = track.Type() == pcbnew.PCB_VIA_T + + if use_net_filter: + # Primary: net-based filter + if track.GetNetname() not in source_nets: + continue + else: + # Fallback: geometric filter – trace start OR end inside source bbox + if is_via: + pos = track.GetPosition() + if not point_in_bbox(pos.x, pos.y): + continue + else: + s = track.GetStart() + e = track.GetEnd() + if not (point_in_bbox(s.x, s.y) or point_in_bbox(e.x, e.y)): + continue + + if is_via: + if include_vias: + vias_to_copy.append(track) + else: + traces_to_copy.append(track) + + 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}" + ) + + # Create new traces with offset + created_traces = 0 + created_vias = 0 + + for track in traces_to_copy: + start = track.GetStart() + end = track.GetEnd() + + # Create new track + new_track = pcbnew.PCB_TRACK(self.board) + new_track.SetStart(pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y)) + new_track.SetEnd(pcbnew.VECTOR2I(end.x + offset_x, end.y + offset_y)) + new_track.SetLayer(track.GetLayer()) + + # Set width (use override or original) + if trace_width: + new_track.SetWidth(int(trace_width * scale)) + else: + new_track.SetWidth(track.GetWidth()) + + # Try to find corresponding target net + # This is a simplification - more sophisticated mapping would be needed + # for complex designs + self.board.Add(new_track) + created_traces += 1 + + for via in vias_to_copy: + pos = via.GetPosition() + + # Create new via + new_via = pcbnew.PCB_VIA(self.board) + new_via.SetPosition(pcbnew.VECTOR2I(pos.x + offset_x, pos.y + offset_y)) + new_via.SetWidth(via.GetWidth(pcbnew.F_Cu)) + new_via.SetDrill(via.GetDrillValue()) + new_via.SetViaType(via.GetViaType()) + + self.board.Add(new_via) + created_vias += 1 + + result = { + "success": True, + "message": f"Copied routing pattern: {created_traces} traces, {created_vias} vias", + "filterMethod": filter_method, + "offset": {"x": offset_x / scale, "y": offset_y / scale, "unit": "mm"}, + "createdTraces": created_traces, + "createdVias": created_vias, + "sourceComponents": source_refs, + "targetComponents": target_refs, + } + + return result + + except Exception as e: + logger.error(f"Error copying routing pattern: {str(e)}") + return { + "success": False, + "message": "Failed to copy routing pattern", + "errorDetails": str(e), + } + + def create_netclass(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Create a new net class with specified properties""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + name = params.get("name") + clearance = params.get("clearance") + track_width = params.get("trackWidth") + via_diameter = params.get("viaDiameter") + via_drill = params.get("viaDrill") + uvia_diameter = params.get("uviaDiameter") + uvia_drill = params.get("uviaDrill") + diff_pair_width = params.get("diffPairWidth") + diff_pair_gap = params.get("diffPairGap") + nets = params.get("nets", []) + + if not name: + return { + "success": False, + "message": "Missing netclass name", + "errorDetails": "name parameter is required", + } + + # Get net classes + net_classes = self.board.GetNetClasses() + + # Create new net class if it doesn't exist + if not net_classes.Find(name): + netclass = pcbnew.NETCLASS(name) + net_classes.Add(netclass) + else: + netclass = net_classes.Find(name) + + # Set properties + scale = 1000000 # mm to nm + if clearance is not None: + netclass.SetClearance(int(clearance * scale)) + if track_width is not None: + netclass.SetTrackWidth(int(track_width * scale)) + if via_diameter is not None: + netclass.SetViaDiameter(int(via_diameter * scale)) + if via_drill is not None: + netclass.SetViaDrill(int(via_drill * scale)) + if uvia_diameter is not None: + netclass.SetMicroViaDiameter(int(uvia_diameter * scale)) + if uvia_drill is not None: + netclass.SetMicroViaDrill(int(uvia_drill * scale)) + if diff_pair_width is not None: + netclass.SetDiffPairWidth(int(diff_pair_width * scale)) + if diff_pair_gap is not None: + netclass.SetDiffPairGap(int(diff_pair_gap * scale)) + + # Add nets to net class + netinfo = self.board.GetNetInfo() + nets_map = netinfo.NetsByName() + for net_name in nets: + if nets_map.has_key(net_name): + net = nets_map[net_name] + net.SetClass(netclass) + + return { + "success": True, + "message": f"Created net class: {name}", + "netClass": { + "name": name, + "clearance": netclass.GetClearance() / scale, + "trackWidth": netclass.GetTrackWidth() / scale, + "viaDiameter": netclass.GetViaDiameter() / scale, + "viaDrill": netclass.GetViaDrill() / scale, + "uviaDiameter": netclass.GetMicroViaDiameter() / scale, + "uviaDrill": netclass.GetMicroViaDrill() / scale, + "diffPairWidth": netclass.GetDiffPairWidth() / scale, + "diffPairGap": netclass.GetDiffPairGap() / scale, + "nets": nets, + }, + } + + except Exception as e: + logger.error(f"Error creating net class: {str(e)}") + return { + "success": False, + "message": "Failed to create net class", + "errorDetails": str(e), + } + + def add_copper_pour(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Add a copper pour (zone) to the PCB""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + layer = params.get("layer", "F.Cu") + net = params.get("net") + clearance = params.get("clearance") + min_width = params.get("minWidth", 0.2) + 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: + 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 + + # Detect corner radius from Edge.Cuts arcs so the zone rectangle + # stays inside the rounded board corners (avoids zone visually + # extending outside Edge.Cuts before refill) + corner_radius = 0.0 + edge_layer_id = self.board.GetLayerID("Edge.Cuts") + for item in self.board.GetDrawings(): + if item.GetLayer() == edge_layer_id and item.GetClass() == "PCB_ARC": + r = item.GetRadius() / scale + if r > corner_radius: + corner_radius = r + # Inset the zone rectangle by the corner radius so its corners + # lie on the straight portions of the board edge. + inset = corner_radius + points = [ + {"x": x1 + inset, "y": y1 + inset}, + {"x": x2 - inset, "y": y1 + inset}, + {"x": x2 - inset, "y": y2 - inset}, + {"x": x1 + inset, "y": y2 - inset}, + ] + 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) + if layer_id < 0: + return { + "success": False, + "message": "Invalid layer", + "errorDetails": f"Layer '{layer}' does not exist", + } + + # Create zone + zone = pcbnew.ZONE(self.board) + zone.SetLayer(layer_id) + + # Set net if provided + if net: + netinfo = self.board.GetNetInfo() + nets_map = netinfo.NetsByName() + if nets_map.has_key(net): + net_obj = nets_map[net] + zone.SetNet(net_obj) + + # Set zone properties + scale = 1000000 # mm to nm + zone.SetAssignedPriority(priority) + + if clearance is not None: + zone.SetLocalClearance(int(clearance * scale)) + + zone.SetMinThickness(int(min_width * scale)) + + # Set fill type + if fill_type == "hatched": + zone.SetFillMode(pcbnew.ZONE_FILL_MODE_HATCH_PATTERN) + else: + zone.SetFillMode(pcbnew.ZONE_FILL_MODE_POLYGONS) + + # Create outline + outline = zone.Outline() + outline.NewOutline() # Create a new outline contour first + + # Add points to outline + for point in points: + scale = 1000000 if point.get("unit", "mm") == "mm" else 25400000 + x_nm = int(point["x"] * scale) + y_nm = int(point["y"] * scale) + outline.Append(pcbnew.VECTOR2I(x_nm, y_nm)) # Add point to outline + + # Add zone to board + self.board.Add(zone) + + # Fill zone + # Note: Zone filling can cause issues with SWIG API + # Comment out for now - zones will be filled when board is saved/opened in KiCAD + # filler = pcbnew.ZONE_FILLER(self.board) + # filler.Fill(self.board.Zones()) + + return { + "success": True, + "message": "Added copper pour", + "pour": { + "layer": layer, + "net": net, + "clearance": clearance, + "minWidth": min_width, + "priority": priority, + "fillType": fill_type, + "pointCount": len(points), + }, + } + + except Exception as e: + logger.error(f"Error adding copper pour: {str(e)}") + return { + "success": False, + "message": "Failed to add copper pour", + "errorDetails": str(e), + } + + def route_differential_pair(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Route a differential pair between two sets of points or pads""" + try: + if not self.board: + return { + "success": False, + "message": "No board is loaded", + "errorDetails": "Load or create a board first", + } + + start_pos = params.get("startPos") + end_pos = params.get("endPos") + net_pos = params.get("netPos") + net_neg = params.get("netNeg") + layer = params.get("layer", "F.Cu") + width = params.get("width") + gap = params.get("gap") + + if not start_pos or not end_pos or not net_pos or not net_neg: + return { + "success": False, + "message": "Missing parameters", + "errorDetails": "startPos, endPos, netPos, and netNeg are required", + } + + # Get layer ID + layer_id = self.board.GetLayerID(layer) + if layer_id < 0: + return { + "success": False, + "message": "Invalid layer", + "errorDetails": f"Layer '{layer}' does not exist", + } + + # Get nets + netinfo = self.board.GetNetInfo() + nets_map = netinfo.NetsByName() + + net_pos_obj = nets_map[net_pos] if nets_map.has_key(net_pos) else None + net_neg_obj = nets_map[net_neg] if nets_map.has_key(net_neg) else None + + if not net_pos_obj or not net_neg_obj: + return { + "success": False, + "message": "Nets not found", + "errorDetails": "One or both nets specified for the differential pair do not exist", + } + + # Get start and end points + start_point = self._get_point(start_pos) + end_point = self._get_point(end_pos) + + # Calculate offset vectors for the two traces + # First, get the direction vector from start to end + dx = end_point.x - start_point.x + dy = end_point.y - start_point.y + length = math.sqrt(dx * dx + dy * dy) + + if length <= 0: + return { + "success": False, + "message": "Invalid points", + "errorDetails": "Start and end points must be different", + } + + # Normalize direction vector + dx /= length + dy /= length + + # Get perpendicular vector + px = -dy + py = dx + + # Set default gap if not provided + if gap is None: + gap = 0.2 # mm + + # Convert to nm + gap_nm = int(gap * 1000000) + + # Calculate offsets + offset_x = int(px * gap_nm / 2) + offset_y = int(py * gap_nm / 2) + + # Create positive and negative trace points + pos_start = pcbnew.VECTOR2I( + int(start_point.x + offset_x), int(start_point.y + offset_y) + ) + pos_end = pcbnew.VECTOR2I(int(end_point.x + offset_x), int(end_point.y + offset_y)) + neg_start = pcbnew.VECTOR2I( + int(start_point.x - offset_x), int(start_point.y - offset_y) + ) + neg_end = pcbnew.VECTOR2I(int(end_point.x - offset_x), int(end_point.y - offset_y)) + + # Create positive trace + pos_track = pcbnew.PCB_TRACK(self.board) + pos_track.SetStart(pos_start) + pos_track.SetEnd(pos_end) + pos_track.SetLayer(layer_id) + pos_track.SetNet(net_pos_obj) + + # Create negative trace + neg_track = pcbnew.PCB_TRACK(self.board) + neg_track.SetStart(neg_start) + neg_track.SetEnd(neg_end) + neg_track.SetLayer(layer_id) + neg_track.SetNet(net_neg_obj) + + # Set width + if width: + trace_width_nm = int(width * 1000000) + pos_track.SetWidth(trace_width_nm) + neg_track.SetWidth(trace_width_nm) + else: + # Get default width from design rules or net class + trace_width = self.board.GetDesignSettings().GetCurrentTrackWidth() + pos_track.SetWidth(trace_width) + neg_track.SetWidth(trace_width) + + # Add tracks to board + self.board.Add(pos_track) + self.board.Add(neg_track) + + return { + "success": True, + "message": "Added differential pair traces", + "diffPair": { + "posNet": net_pos, + "negNet": net_neg, + "layer": layer, + "width": pos_track.GetWidth() / 1000000, + "gap": gap, + "length": length / 1000000, + }, + } + + except Exception as e: + logger.error(f"Error routing differential pair: {str(e)}") + return { + "success": False, + "message": "Failed to route differential pair", + "errorDetails": str(e), + } + + def _get_point(self, point_spec: Dict[str, Any]) -> pcbnew.VECTOR2I: + """Convert point specification to KiCAD point""" + if "x" in point_spec and "y" in point_spec: + scale = 1000000 if point_spec.get("unit", "mm") == "mm" else 25400000 + x_nm = int(point_spec["x"] * scale) + y_nm = int(point_spec["y"] * scale) + return pcbnew.VECTOR2I(x_nm, y_nm) + elif "pad" in point_spec and "componentRef" in point_spec: + module = self.board.FindFootprintByReference(point_spec["componentRef"]) + if module: + pad = module.FindPadByName(point_spec["pad"]) + if pad: + return pad.GetPosition() + raise ValueError("Invalid point specification") + + def _point_to_track_distance(self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK) -> float: + """Calculate distance from point to track segment""" + start = track.GetStart() + end = track.GetEnd() + + # Vector from start to end + v = pcbnew.VECTOR2I(end.x - start.x, end.y - start.y) + # Vector from start to point + w = pcbnew.VECTOR2I(point.x - start.x, point.y - start.y) + + # Length of track squared + c1 = v.x * v.x + v.y * v.y + if c1 == 0: + return self._point_distance(point, start) + + # Projection coefficient + c2 = float(w.x * v.x + w.y * v.y) / c1 + + if c2 < 0: + return self._point_distance(point, start) + elif c2 > 1: + return self._point_distance(point, end) + + # Point on line + proj = pcbnew.VECTOR2I(int(start.x + c2 * v.x), int(start.y + c2 * v.y)) + return self._point_distance(point, proj) + + def _point_distance(self, p1: pcbnew.VECTOR2I, p2: pcbnew.VECTOR2I) -> float: + """Calculate distance between two points""" + dx = p1.x - p2.x + dy = p1.y - p2.y + return (dx * dx + dy * dy) ** 0.5 diff --git a/python/commands/schematic_analysis.py b/python/commands/schematic_analysis.py index cd269a3..01a5669 100644 --- a/python/commands/schematic_analysis.py +++ b/python/commands/schematic_analysis.py @@ -1,976 +1,976 @@ -""" -Schematic Analysis Tools for KiCad Schematics - -Read-only analysis tools for detecting spatial problems, querying regions, -and checking connectivity in KiCad schematic files. -""" - -import logging -import math -from collections import defaultdict -from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple - -import sexpdata -from commands.pin_locator import PinLocator -from commands.wire_connectivity import _parse_virtual_connections, _to_iu -from sexpdata import Symbol -from skip import Schematic - -logger = logging.getLogger("kicad_interface") - - -# --------------------------------------------------------------------------- -# S-expression parsing helpers -# --------------------------------------------------------------------------- - - -def _load_sexp(schematic_path: Path) -> list: - """Load schematic file and return parsed S-expression data.""" - with open(schematic_path, "r", encoding="utf-8") as f: - return sexpdata.loads(f.read()) - - -def _parse_wires(sexp_data: list) -> List[Dict[str, Any]]: - """ - Parse all wire segments from the schematic S-expression. - - Returns list of dicts: {start: (x_mm, y_mm), end: (x_mm, y_mm)} - """ - wires = [] - for item in sexp_data: - if not isinstance(item, list) or len(item) < 2: - continue - if item[0] != Symbol("wire"): - continue - pts = None - for sub in item: - if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"): - pts = sub - break - if not pts: - continue - coords = [] - for sub in pts: - if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("xy"): - coords.append((float(sub[1]), float(sub[2]))) - if len(coords) >= 2: - wires.append({"start": coords[0], "end": coords[1]}) - return wires - - -def _parse_labels(sexp_data: list) -> List[Dict[str, Any]]: - """ - Parse all labels (label and global_label) from the schematic S-expression. - - Returns list of dicts: {name, type ('label'|'global_label'), x, y} - """ - labels = [] - for item in sexp_data: - if not isinstance(item, list) or len(item) < 2: - continue - tag = item[0] - if tag not in (Symbol("label"), Symbol("global_label")): - continue - name = str(item[1]).strip('"') - label_type = str(tag) - x, y = 0.0, 0.0 - for sub in item: - if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("at"): - x = float(sub[1]) - y = float(sub[2]) - break - labels.append({"name": name, "type": label_type, "x": x, "y": y}) - return labels - - -def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]: - """ - Parse all placed symbol instances from the schematic S-expression. - - Returns list of dicts: {reference, lib_id, x, y, rotation, mirror_x, mirror_y, is_power} - """ - symbols = [] - for item in sexp_data: - if not isinstance(item, list) or len(item) < 2: - continue - if item[0] != Symbol("symbol"): - continue - - lib_id = "" - x, y, rotation = 0.0, 0.0, 0.0 - reference = "" - is_power = False - mirror_x = False - mirror_y = False - - for sub in item: - if isinstance(sub, list) and len(sub) >= 2: - if sub[0] == Symbol("lib_id"): - lib_id = str(sub[1]).strip('"') - elif sub[0] == Symbol("at") and len(sub) >= 3: - x = float(sub[1]) - y = float(sub[2]) - if len(sub) >= 4: - rotation = float(sub[3]) - elif sub[0] == Symbol("mirror"): - m = str(sub[1]) - if m == "x": - mirror_x = True - elif m == "y": - mirror_y = True - elif sub[0] == Symbol("property") and len(sub) >= 3: - prop_name = str(sub[1]).strip('"') - if prop_name == "Reference": - reference = str(sub[2]).strip('"') - - is_power = reference.startswith("#PWR") or reference.startswith("#FLG") - symbols.append( - { - "reference": reference, - "lib_id": lib_id, - "x": x, - "y": y, - "rotation": rotation, - "mirror_x": mirror_x, - "mirror_y": mirror_y, - "is_power": is_power, - } - ) - return symbols - - -def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]: - """ - Parse graphical body elements from a lib_symbol definition and return - local-coordinate bounding points. - - Extracts points from rectangle, polyline, circle, arc, and bezier - elements found in sub-symbols (typically the ``_0_1`` layers that - contain body shapes). - - Returns a list of ``(x, y)`` points in local symbol coordinates. - """ - points: List[Tuple[float, float]] = [] - - def _extract_graphics_recursive(sexp: list) -> None: - if not isinstance(sexp, list) or len(sexp) == 0: - return - - tag = sexp[0] - - if tag == Symbol("rectangle"): - # (rectangle (start x y) (end x y) ...) - for sub in sexp[1:]: - if isinstance(sub, list) and len(sub) >= 3: - if sub[0] in (Symbol("start"), Symbol("end")): - points.append((float(sub[1]), float(sub[2]))) - - elif tag == Symbol("polyline"): - # (polyline (pts (xy x y) (xy x y) ...) ...) - for sub in sexp[1:]: - if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"): - for pt in sub[1:]: - if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"): - points.append((float(pt[1]), float(pt[2]))) - - elif tag == Symbol("circle"): - # (circle (center x y) (radius r) ...) - cx, cy, r = 0.0, 0.0, 0.0 - for sub in sexp[1:]: - if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("center"): - cx, cy = float(sub[1]), float(sub[2]) - elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"): - r = float(sub[1]) - if r > 0: - points.extend( - [ - (cx - r, cy - r), - (cx + r, cy + r), - ] - ) - - elif tag == Symbol("arc"): - # (arc (start x y) (mid x y) (end x y) ...) - for sub in sexp[1:]: - if isinstance(sub, list) and len(sub) >= 3: - if sub[0] in (Symbol("start"), Symbol("mid"), Symbol("end")): - points.append((float(sub[1]), float(sub[2]))) - - elif tag == Symbol("bezier"): - # (bezier (pts (xy x y) ...) ...) - for sub in sexp[1:]: - if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"): - for pt in sub[1:]: - if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"): - points.append((float(pt[1]), float(pt[2]))) - - else: - # Recurse into sub-symbols to find graphics in nested definitions - for sub in sexp[1:]: - if isinstance(sub, list): - _extract_graphics_recursive(sub) - - # Search the top-level symbol definition and its sub-symbols - for item in symbol_def[1:]: - if isinstance(item, list): - _extract_graphics_recursive(item) - - return points - - -def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]: - """ - Walk the lib_symbols section of already-parsed sexp_data and return - pin definitions and graphics points for every symbol definition. - - Returns: - Dict mapping lib_id → {"pins": pin_defs, "graphics_points": [(x,y), ...]}. - """ - lib_symbols_section = None - for item in sexp_data: - if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"): - lib_symbols_section = item - break - - if not lib_symbols_section: - return {} - - result: Dict[str, Dict] = {} - for item in lib_symbols_section[1:]: - if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"): - symbol_name = str(item[1]).strip('"') - result[symbol_name] = { - "pins": PinLocator.parse_symbol_definition(item), - "graphics_points": _parse_lib_symbol_graphics(item), - } - return result - - -# --------------------------------------------------------------------------- -# Geometry helpers -# --------------------------------------------------------------------------- - - -def compute_symbol_bbox( - schematic_path: Path, - reference: str, - locator: PinLocator, -) -> Optional[Tuple[float, float, float, float]]: - """ - Compute bounding box of a symbol from its pin positions. - - Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins found. - """ - pins = locator.get_all_symbol_pins(schematic_path, reference) - if not pins: - return None - xs = [p[0] for p in pins.values()] - ys = [p[1] for p in pins.values()] - return (min(xs), min(ys), max(xs), max(ys)) - - -def _line_segment_intersects_aabb( - x1: float, - y1: float, - x2: float, - y2: float, - box_min_x: float, - box_min_y: float, - box_max_x: float, - box_max_y: float, -) -> bool: - """ - Test whether line segment (x1,y1)→(x2,y2) intersects an axis-aligned bounding box. - - Uses the Liang-Barsky clipping algorithm. - """ - dx = x2 - x1 - dy = y2 - y1 - - p = [-dx, dx, -dy, dy] - q = [x1 - box_min_x, box_max_x - x1, y1 - box_min_y, box_max_y - y1] - - t_min = 0.0 - t_max = 1.0 - - for i in range(4): - if abs(p[i]) < 1e-12: - # Parallel to this edge - if q[i] < 0: - return False - else: - t = q[i] / p[i] - if p[i] < 0: - t_min = max(t_min, t) - else: - t_max = min(t_max, t) - if t_min > t_max: - return False - - return True - - -def _point_in_rect( - px: float, - py: float, - min_x: float, - min_y: float, - max_x: float, - max_y: float, -) -> bool: - """Check if a point is within a rectangle.""" - return min_x <= px <= max_x and min_y <= py <= max_y - - -def _distance(p1: Tuple[float, float], p2: Tuple[float, float]) -> float: - """Euclidean distance between two points.""" - return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2) - - -def _aabb_overlap( - a: Tuple[float, float, float, float], - b: Tuple[float, float, float, float], -) -> bool: - """Check if two axis-aligned bounding boxes overlap. - - Each bbox is (min_x, min_y, max_x, max_y). - """ - return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3] - - -def _transform_local_point( - lx: float, - ly: float, - sym_x: float, - sym_y: float, - rotation: float, - mirror_x: bool, - mirror_y: bool, -) -> Tuple[float, float]: - """ - Transform a point from local symbol coordinates to absolute schematic - coordinates using KiCad's transform order: - negate-y (lib y-up → schematic y-down) → mirror → rotate → translate. - """ - # Library symbols use y-up; schematic uses y-down - ly = -ly - - # Apply mirroring in local coords - if mirror_x: - ly = -ly - if mirror_y: - lx = -lx - - # Apply rotation - if rotation != 0: - lx, ly = PinLocator.rotate_point(lx, ly, rotation) - - return (sym_x + lx, sym_y + ly) - - -def _compute_symbol_bbox_direct( - sym: Dict[str, Any], - pin_defs: Dict[str, Dict], - margin: float = 0.0, - graphics_points: Optional[List[Tuple[float, float]]] = None, -) -> Optional[Tuple[float, float, float, float]]: - """ - Compute bounding box of a symbol from its graphics and pin definitions. - - When graphics_points are available (from lib_symbol body shapes), uses - those for the bbox and unions with pin positions. Falls back to - pin-only estimation with degenerate expansion when no graphics data - is available. - - Args: - sym: Parsed symbol dict with x, y, rotation, mirror_x, mirror_y. - pin_defs: Pin definitions from PinLocator.get_symbol_pins(). - margin: Shrink bbox by this amount on each side (mm). - graphics_points: Local-coordinate points from symbol body graphics. - - Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins. - """ - pin_positions = _compute_pin_positions_direct(sym, pin_defs) - if not pin_positions: - return None - - if graphics_points: - # Transform graphics points to absolute coordinates - sym_x, sym_y = sym["x"], sym["y"] - rotation = sym["rotation"] - mirror_x = sym.get("mirror_x", False) - mirror_y = sym.get("mirror_y", False) - - abs_points = [ - _transform_local_point(lx, ly, sym_x, sym_y, rotation, mirror_x, mirror_y) - for lx, ly in graphics_points - ] - - # Union with pin positions so pins extending beyond body are included - all_xs = [p[0] for p in abs_points] + [p[0] for p in pin_positions.values()] - all_ys = [p[1] for p in abs_points] + [p[1] for p in pin_positions.values()] - - min_x, min_y = min(all_xs), min(all_ys) - max_x, max_y = max(all_xs), max(all_ys) - else: - # Fallback: pin-only estimation with degenerate expansion - xs = [p[0] for p in pin_positions.values()] - ys = [p[1] for p in pin_positions.values()] - min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys) - - min_body = 1.5 # mm minimum half-extent for component body - if max_x - min_x < 2 * min_body: - cx = (min_x + max_x) / 2 - min_x = cx - min_body - max_x = cx + min_body - if max_y - min_y < 2 * min_body: - cy = (min_y + max_y) / 2 - min_y = cy - min_body - max_y = cy + min_body - - # Shrink bbox by margin - min_x += margin - min_y += margin - max_x -= margin - max_y -= margin - - # Skip degenerate bboxes - if max_x <= min_x or max_y <= min_y: - return None - - return (min_x, min_y, max_x, max_y) - - -# --------------------------------------------------------------------------- -# Tool 3: find_overlapping_elements -# --------------------------------------------------------------------------- - - -def find_overlapping_elements(schematic_path: Path, tolerance: float = 0.5) -> Dict[str, Any]: - """ - Detect spatially overlapping symbols, wires, and labels. - - Args: - schematic_path: Path to .kicad_sch file - tolerance: Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. - - Returns dict: {overlappingSymbols, overlappingLabels, overlappingWires, totalOverlaps} - """ - sexp_data = _load_sexp(schematic_path) - symbols = _parse_symbols(sexp_data) - wires = _parse_wires(sexp_data) - labels = _parse_labels(sexp_data) - - overlapping_symbols = [] - overlapping_labels = [] - overlapping_wires = [] - - lib_defs = _extract_lib_symbols(sexp_data) - - # --- Symbol-symbol overlap using bounding-box intersection (O(n²)) --- - non_template_symbols = [ - s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"] - ] - - # Pre-compute bounding boxes for all non-template symbols - symbol_bboxes = [] - for sym in non_template_symbols: - lib_data = lib_defs.get(sym["lib_id"], {}) - pin_defs = lib_data.get("pins", {}) - graphics_points = lib_data.get("graphics_points", []) - bbox = None - if pin_defs: - bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) - symbol_bboxes.append((sym, bbox)) - - for i in range(len(symbol_bboxes)): - s1, bbox1 = symbol_bboxes[i] - for j in range(i + 1, len(symbol_bboxes)): - s2, bbox2 = symbol_bboxes[j] - dist = _distance((s1["x"], s1["y"]), (s2["x"], s2["y"])) - - overlap_detected = False - if bbox1 is not None and bbox2 is not None: - # Use bounding box intersection - overlap_detected = _aabb_overlap(bbox1, bbox2) - else: - # Fallback to center distance when pin data is unavailable - overlap_detected = dist < tolerance - - if overlap_detected: - entry = { - "element1": { - "reference": s1["reference"], - "libId": s1["lib_id"], - "position": {"x": s1["x"], "y": s1["y"]}, - }, - "element2": { - "reference": s2["reference"], - "libId": s2["lib_id"], - "position": {"x": s2["x"], "y": s2["y"]}, - }, - "distance": round(dist, 4), - } - # Flag power symbol pairs specifically - if s1["is_power"] and s2["is_power"]: - entry["type"] = "power_symbol_overlap" - else: - entry["type"] = "symbol_overlap" - overlapping_symbols.append(entry) - - # --- Label-label overlap --- - for i in range(len(labels)): - for j in range(i + 1, len(labels)): - l1 = labels[i] - l2 = labels[j] - dist = _distance((l1["x"], l1["y"]), (l2["x"], l2["y"])) - if dist < tolerance: - overlapping_labels.append( - { - "element1": { - "name": l1["name"], - "type": l1["type"], - "position": {"x": l1["x"], "y": l1["y"]}, - }, - "element2": { - "name": l2["name"], - "type": l2["type"], - "position": {"x": l2["x"], "y": l2["y"]}, - }, - "distance": round(dist, 4), - } - ) - - # --- Wire-wire collinear overlap --- - for i in range(len(wires)): - for j in range(i + 1, len(wires)): - w1 = wires[i] - w2 = wires[j] - overlap = _check_wire_overlap(w1, w2, tolerance) - if overlap: - overlapping_wires.append(overlap) - - total = len(overlapping_symbols) + len(overlapping_labels) + len(overlapping_wires) - - return { - "overlappingSymbols": overlapping_symbols, - "overlappingLabels": overlapping_labels, - "overlappingWires": overlapping_wires, - "totalOverlaps": total, - } - - -def _check_wire_overlap( - w1: Dict[str, Any], w2: Dict[str, Any], tolerance: float -) -> Optional[Dict[str, Any]]: - """ - Check if two wire segments are collinear and overlapping. - - Works for horizontal, vertical, and diagonal wires. Uses direction - vectors, cross-product parallelism, point-to-line distance for - collinearity, and 1D projection overlap. - - Returns overlap info dict or None. - """ - s1, e1 = w1["start"], w1["end"] - s2, e2 = w2["start"], w2["end"] - - d1 = (e1[0] - s1[0], e1[1] - s1[1]) - d2 = (e2[0] - s2[0], e2[1] - s2[1]) - - len1 = math.sqrt(d1[0] ** 2 + d1[1] ** 2) - len2 = math.sqrt(d2[0] ** 2 + d2[1] ** 2) - if len1 < 1e-12 or len2 < 1e-12: - return None # degenerate zero-length segment - - # Cross product to check parallel - cross = d1[0] * d2[1] - d1[1] * d2[0] - if abs(cross) > tolerance * max(len1, len2): - return None # not parallel - - # Point-to-line distance: s2 relative to line through s1 along d1 - ds = (s2[0] - s1[0], s2[1] - s1[1]) - perp_dist = abs(ds[0] * d1[1] - ds[1] * d1[0]) / len1 - if perp_dist > tolerance: - return None # parallel but offset - - # Project onto d1 direction for 1D overlap check - u1 = (d1[0] / len1, d1[1] / len1) - proj_s1 = s1[0] * u1[0] + s1[1] * u1[1] - proj_e1 = e1[0] * u1[0] + e1[1] * u1[1] - proj_s2 = s2[0] * u1[0] + s2[1] * u1[1] - proj_e2 = e2[0] * u1[0] + e2[1] * u1[1] - - min1, max1 = min(proj_s1, proj_e1), max(proj_s1, proj_e1) - min2, max2 = min(proj_s2, proj_e2), max(proj_s2, proj_e2) - if min1 < max2 and min2 < max1: - return { - "wire1": { - "start": {"x": s1[0], "y": s1[1]}, - "end": {"x": e1[0], "y": e1[1]}, - }, - "wire2": { - "start": {"x": s2[0], "y": s2[1]}, - "end": {"x": e2[0], "y": e2[1]}, - }, - "type": "collinear_overlap", - } - - return None - - -# --------------------------------------------------------------------------- -# Tool 4: get_elements_in_region -# --------------------------------------------------------------------------- - - -def get_elements_in_region( - schematic_path: Path, - x1: float, - y1: float, - x2: float, - y2: float, -) -> Dict[str, Any]: - """ - List all wires, labels, and symbols within a rectangular region. - - Args: - schematic_path: Path to .kicad_sch file - x1, y1, x2, y2: Bounding box corners in schematic mm - - Returns dict: {symbols, wires, labels, counts} - """ - min_x, max_x = min(x1, x2), max(x1, x2) - min_y, max_y = min(y1, y2), max(y1, y2) - - sexp_data = _load_sexp(schematic_path) - symbols = _parse_symbols(sexp_data) - wires = _parse_wires(sexp_data) - labels = _parse_labels(sexp_data) - - lib_defs = _extract_lib_symbols(sexp_data) - - # Symbols: include if position is within bounds - region_symbols = [] - for sym in symbols: - if not sym["reference"] or sym["reference"].startswith("_TEMPLATE"): - continue - if _point_in_rect(sym["x"], sym["y"], min_x, min_y, max_x, max_y): - entry = { - "reference": sym["reference"], - "libId": sym["lib_id"], - "position": {"x": sym["x"], "y": sym["y"]}, - "isPower": sym["is_power"], - } - # Include pin positions (compute directly to handle unannotated duplicates) - lib_data = lib_defs.get(sym["lib_id"], {}) - pin_defs = lib_data.get("pins", {}) - if pin_defs: - pin_positions = _compute_pin_positions_direct(sym, pin_defs) - if pin_positions: - entry["pins"] = { - pn: {"x": round(pos[0], 4), "y": round(pos[1], 4)} - for pn, pos in pin_positions.items() - } - region_symbols.append(entry) - - # Wires: include if any part of the wire intersects the region - region_wires = [] - for w in wires: - s, e = w["start"], w["end"] - if ( - _point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y) - or _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y) - or _line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y) - ): - region_wires.append( - { - "start": {"x": s[0], "y": s[1]}, - "end": {"x": e[0], "y": e[1]}, - } - ) - - # Labels: include if position is within bounds - region_labels = [] - for lbl in labels: - if _point_in_rect(lbl["x"], lbl["y"], min_x, min_y, max_x, max_y): - region_labels.append( - { - "name": lbl["name"], - "type": lbl["type"], - "position": {"x": lbl["x"], "y": lbl["y"]}, - } - ) - - return { - "symbols": region_symbols, - "wires": region_wires, - "labels": region_labels, - "counts": { - "symbols": len(region_symbols), - "wires": len(region_wires), - "labels": len(region_labels), - }, - } - - -# --------------------------------------------------------------------------- -# Tool 5: check_wire_collisions -# --------------------------------------------------------------------------- - - -def _compute_pin_positions_direct( - sym: Dict[str, Any], pin_defs: Dict[str, Dict] -) -> Dict[str, List[float]]: - """ - Compute absolute schematic pin positions for a symbol instance directly from - its parsed position/rotation/mirror data and pin definitions in local coords. - - Unlike PinLocator.get_all_symbol_pins, this does NOT do a reference-name - lookup in the schematic, so it works correctly when multiple symbols share - the same reference designator (e.g. unannotated "Q?"). - - KiCad transform order: mirror (in local coords) → rotate → translate. - """ - sym_x = sym["x"] - sym_y = sym["y"] - rotation = sym["rotation"] - mirror_x = sym.get("mirror_x", False) - mirror_y = sym.get("mirror_y", False) - - result: Dict[str, List[float]] = {} - for pin_num, pin_data in pin_defs.items(): - rel_x = float(pin_data["x"]) - rel_y = float(pin_data["y"]) - - # Apply mirroring in local symbol coordinates - if mirror_x: - rel_y = -rel_y - if mirror_y: - rel_x = -rel_x - - # Apply symbol rotation - if rotation != 0: - rel_x, rel_y = PinLocator.rotate_point(rel_x, rel_y, rotation) - - result[pin_num] = [sym_x + rel_x, sym_y + rel_y] - return result - - -def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: - """ - Find all wires that cross over component symbol bodies. - - Wires passing over symbols are unacceptable in schematics — they indicate - routing mistakes where a wire was drawn across a component instead of - around it. - - For each non-power, non-template symbol: - 1. Compute bounding box from pin positions (shrunk by margin). - 2. For each wire segment, test intersection with the bbox. - 3. If intersects and the wire is not simply terminating at a pin from - outside, report it as a crossing. - - Returns list of crossing dicts. - """ - sexp_data = _load_sexp(schematic_path) - symbols = _parse_symbols(sexp_data) - wires = _parse_wires(sexp_data) - - lib_defs = _extract_lib_symbols(sexp_data) - margin = 0.5 # mm margin to shrink bbox (avoids false positives at pin tips) - pin_tolerance = 0.05 # mm - - collisions = [] - - # Pre-compute per-symbol data - symbol_data: List[Dict[str, Any]] = [] - for sym in symbols: - ref = sym["reference"] - if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: - continue - - lib_data = lib_defs.get(sym["lib_id"], {}) - pin_defs = lib_data.get("pins", {}) - if not pin_defs: - continue - - graphics_points = lib_data.get("graphics_points", []) - bbox = _compute_symbol_bbox_direct( - sym, pin_defs, margin=margin, graphics_points=graphics_points - ) - if bbox is None: - continue - - pin_positions = _compute_pin_positions_direct(sym, pin_defs) - pin_set = set() - for pos in pin_positions.values(): - pin_set.add((pos[0], pos[1])) - - symbol_data.append( - { - "sym": sym, - "bbox": bbox, - "pin_set": pin_set, - } - ) - - # Test each wire against each symbol bbox - for w in wires: - sx, sy = w["start"] - ex, ey = w["end"] - - for sd in symbol_data: - bx1, by1, bx2, by2 = sd["bbox"] - - if not _line_segment_intersects_aabb(sx, sy, ex, ey, bx1, by1, bx2, by2): - continue - - # Check which endpoints land on a pin of this symbol - start_at_pin = any( - abs(sx - px) < pin_tolerance and abs(sy - py) < pin_tolerance - for px, py in sd["pin_set"] - ) - end_at_pin = any( - abs(ex - px) < pin_tolerance and abs(ey - py) < pin_tolerance - for px, py in sd["pin_set"] - ) - - # When exactly one endpoint is at a pin, check whether the wire - # just terminates at the pin (valid connection) or continues through - # the component body (pass-through → collision). - # Nudge the pin endpoint slightly toward the other end; if the - # shortened segment still intersects the bbox, the wire extends - # into/through the body. - if (start_at_pin or end_at_pin) and not (start_at_pin and end_at_pin): - dx, dy = ex - sx, ey - sy - length = math.sqrt(dx * dx + dy * dy) - if length > 0: - nudge = min(0.2, length * 0.5) - ux, uy = dx / length, dy / length - if start_at_pin: - nsx, nsy = sx + ux * nudge, sy + uy * nudge - if not _line_segment_intersects_aabb(nsx, nsy, ex, ey, bx1, by1, bx2, by2): - continue # Wire terminates at pin from outside - else: - nex, ney = ex - ux * nudge, ey - uy * nudge - if not _line_segment_intersects_aabb(sx, sy, nex, ney, bx1, by1, bx2, by2): - continue # Wire terminates at pin from outside - - sym = sd["sym"] - collisions.append( - { - "wire": { - "start": {"x": sx, "y": sy}, - "end": {"x": ex, "y": ey}, - }, - "component": { - "reference": sym["reference"], - "libId": sym["lib_id"], - "position": {"x": sym["x"], "y": sym["y"]}, - }, - "intersectionType": "passes_through", - } - ) - - return collisions - - -def find_orphaned_wires(schematic_path: Path) -> Dict[str, Any]: - """ - Find wire segments with at least one dangling endpoint. - - A wire endpoint is dangling when the IU point at that endpoint satisfies - all three conditions simultaneously: - 1. No other wire shares that IU endpoint (would imply a junction / T-join) - 2. No component pin is at that IU point - 3. No net label or power symbol pin is at that IU point - - Uses exact KiCad IU matching (10 000 IU/mm) — same strategy as - wire_connectivity.py — to avoid floating-point tolerance issues. - - Returns: - { - "orphaned_wires": [ - { - "start": {"x": float, "y": float}, - "end": {"x": float, "y": float}, - "dangling_ends": [{"x": float, "y": float}, ...] - }, - ... - ], - "count": int - } - """ - sexp_data = _load_sexp(schematic_path) - - # --- wire endpoints in mm and IU --- - wires_mm = _parse_wires(sexp_data) - wires_iu: List[Tuple[Tuple[int, int], Tuple[int, int]]] = [ - (_to_iu(*w["start"]), _to_iu(*w["end"])) for w in wires_mm - ] - - # Count how many wires touch each IU endpoint - iu_to_count: Dict[Tuple[int, int], int] = defaultdict(int) - for s_iu, e_iu in wires_iu: - iu_to_count[s_iu] += 1 - iu_to_count[e_iu] += 1 - - # --- anchors: component pins --- - pin_iu: Set[Tuple[int, int]] = set() - try: - locator = PinLocator() - sch = Schematic(str(schematic_path)) - for symbol in sch.symbol: - try: - if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): - continue - ref = symbol.property.Reference.value - if ref.startswith("_TEMPLATE"): - continue - all_pins = locator.get_all_symbol_pins(schematic_path, ref) - for coords in all_pins.values(): - pin_iu.add(_to_iu(float(coords[0]), float(coords[1]))) - except Exception as e: - logger.warning(f"Error reading pins for symbol: {e}") - except Exception as e: - logger.warning(f"Could not load schematic via skip for pin extraction: {e}") - sch = None - - # --- anchors: net labels and global_labels --- - labels = _parse_labels(sexp_data) - label_iu: Set[Tuple[int, int]] = {_to_iu(lbl["x"], lbl["y"]) for lbl in labels} - - # --- anchors: power symbol pins (VCC, GND …) --- - power_iu: Set[Tuple[int, int]] = set() - if sch is not None: - try: - point_to_label, _ = _parse_virtual_connections(sch, schematic_path) - power_iu = set(point_to_label.keys()) - except Exception as e: - logger.warning(f"Could not extract power symbol anchors: {e}") - - anchored_iu = pin_iu | label_iu | power_iu - - # --- classify each wire --- - orphaned: List[Dict[str, Any]] = [] - for i, (s_iu, e_iu) in enumerate(wires_iu): - w = wires_mm[i] - dangling_ends: List[Dict[str, float]] = [] - for pt_iu, pt_mm in [(s_iu, w["start"]), (e_iu, w["end"])]: - if iu_to_count[pt_iu] > 1: - continue # shared with another wire → connected - if pt_iu in anchored_iu: - continue # touches a pin or label → connected - dangling_ends.append({"x": pt_mm[0], "y": pt_mm[1]}) - if dangling_ends: - orphaned.append( - { - "start": {"x": w["start"][0], "y": w["start"][1]}, - "end": {"x": w["end"][0], "y": w["end"][1]}, - "dangling_ends": dangling_ends, - } - ) - - return {"orphaned_wires": orphaned, "count": len(orphaned)} +""" +Schematic Analysis Tools for KiCad Schematics + +Read-only analysis tools for detecting spatial problems, querying regions, +and checking connectivity in KiCad schematic files. +""" + +import logging +import math +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +import sexpdata +from commands.pin_locator import PinLocator +from commands.wire_connectivity import _parse_virtual_connections, _to_iu +from sexpdata import Symbol +from skip import Schematic + +logger = logging.getLogger("kicad_interface") + + +# --------------------------------------------------------------------------- +# S-expression parsing helpers +# --------------------------------------------------------------------------- + + +def _load_sexp(schematic_path: Path) -> list: + """Load schematic file and return parsed S-expression data.""" + with open(schematic_path, "r", encoding="utf-8") as f: + return sexpdata.loads(f.read()) + + +def _parse_wires(sexp_data: list) -> List[Dict[str, Any]]: + """ + Parse all wire segments from the schematic S-expression. + + Returns list of dicts: {start: (x_mm, y_mm), end: (x_mm, y_mm)} + """ + wires = [] + for item in sexp_data: + if not isinstance(item, list) or len(item) < 2: + continue + if item[0] != Symbol("wire"): + continue + pts = None + for sub in item: + if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"): + pts = sub + break + if not pts: + continue + coords = [] + for sub in pts: + if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("xy"): + coords.append((float(sub[1]), float(sub[2]))) + if len(coords) >= 2: + wires.append({"start": coords[0], "end": coords[1]}) + return wires + + +def _parse_labels(sexp_data: list) -> List[Dict[str, Any]]: + """ + Parse all labels (label and global_label) from the schematic S-expression. + + Returns list of dicts: {name, type ('label'|'global_label'), x, y} + """ + labels = [] + for item in sexp_data: + if not isinstance(item, list) or len(item) < 2: + continue + tag = item[0] + if tag not in (Symbol("label"), Symbol("global_label")): + continue + name = str(item[1]).strip('"') + label_type = str(tag) + x, y = 0.0, 0.0 + for sub in item: + if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("at"): + x = float(sub[1]) + y = float(sub[2]) + break + labels.append({"name": name, "type": label_type, "x": x, "y": y}) + return labels + + +def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]: + """ + Parse all placed symbol instances from the schematic S-expression. + + Returns list of dicts: {reference, lib_id, x, y, rotation, mirror_x, mirror_y, is_power} + """ + symbols = [] + for item in sexp_data: + if not isinstance(item, list) or len(item) < 2: + continue + if item[0] != Symbol("symbol"): + continue + + lib_id = "" + x, y, rotation = 0.0, 0.0, 0.0 + reference = "" + is_power = False + mirror_x = False + mirror_y = False + + for sub in item: + if isinstance(sub, list) and len(sub) >= 2: + if sub[0] == Symbol("lib_id"): + lib_id = str(sub[1]).strip('"') + elif sub[0] == Symbol("at") and len(sub) >= 3: + x = float(sub[1]) + y = float(sub[2]) + if len(sub) >= 4: + rotation = float(sub[3]) + elif sub[0] == Symbol("mirror"): + m = str(sub[1]) + if m == "x": + mirror_x = True + elif m == "y": + mirror_y = True + elif sub[0] == Symbol("property") and len(sub) >= 3: + prop_name = str(sub[1]).strip('"') + if prop_name == "Reference": + reference = str(sub[2]).strip('"') + + is_power = reference.startswith("#PWR") or reference.startswith("#FLG") + symbols.append( + { + "reference": reference, + "lib_id": lib_id, + "x": x, + "y": y, + "rotation": rotation, + "mirror_x": mirror_x, + "mirror_y": mirror_y, + "is_power": is_power, + } + ) + return symbols + + +def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]: + """ + Parse graphical body elements from a lib_symbol definition and return + local-coordinate bounding points. + + Extracts points from rectangle, polyline, circle, arc, and bezier + elements found in sub-symbols (typically the ``_0_1`` layers that + contain body shapes). + + Returns a list of ``(x, y)`` points in local symbol coordinates. + """ + points: List[Tuple[float, float]] = [] + + def _extract_graphics_recursive(sexp: list) -> None: + if not isinstance(sexp, list) or len(sexp) == 0: + return + + tag = sexp[0] + + if tag == Symbol("rectangle"): + # (rectangle (start x y) (end x y) ...) + for sub in sexp[1:]: + if isinstance(sub, list) and len(sub) >= 3: + if sub[0] in (Symbol("start"), Symbol("end")): + points.append((float(sub[1]), float(sub[2]))) + + elif tag == Symbol("polyline"): + # (polyline (pts (xy x y) (xy x y) ...) ...) + for sub in sexp[1:]: + if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"): + for pt in sub[1:]: + if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"): + points.append((float(pt[1]), float(pt[2]))) + + elif tag == Symbol("circle"): + # (circle (center x y) (radius r) ...) + cx, cy, r = 0.0, 0.0, 0.0 + for sub in sexp[1:]: + if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("center"): + cx, cy = float(sub[1]), float(sub[2]) + elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"): + r = float(sub[1]) + if r > 0: + points.extend( + [ + (cx - r, cy - r), + (cx + r, cy + r), + ] + ) + + elif tag == Symbol("arc"): + # (arc (start x y) (mid x y) (end x y) ...) + for sub in sexp[1:]: + if isinstance(sub, list) and len(sub) >= 3: + if sub[0] in (Symbol("start"), Symbol("mid"), Symbol("end")): + points.append((float(sub[1]), float(sub[2]))) + + elif tag == Symbol("bezier"): + # (bezier (pts (xy x y) ...) ...) + for sub in sexp[1:]: + if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"): + for pt in sub[1:]: + if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"): + points.append((float(pt[1]), float(pt[2]))) + + else: + # Recurse into sub-symbols to find graphics in nested definitions + for sub in sexp[1:]: + if isinstance(sub, list): + _extract_graphics_recursive(sub) + + # Search the top-level symbol definition and its sub-symbols + for item in symbol_def[1:]: + if isinstance(item, list): + _extract_graphics_recursive(item) + + return points + + +def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]: + """ + Walk the lib_symbols section of already-parsed sexp_data and return + pin definitions and graphics points for every symbol definition. + + Returns: + Dict mapping lib_id → {"pins": pin_defs, "graphics_points": [(x,y), ...]}. + """ + lib_symbols_section = None + for item in sexp_data: + if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"): + lib_symbols_section = item + break + + if not lib_symbols_section: + return {} + + result: Dict[str, Dict] = {} + for item in lib_symbols_section[1:]: + if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"): + symbol_name = str(item[1]).strip('"') + result[symbol_name] = { + "pins": PinLocator.parse_symbol_definition(item), + "graphics_points": _parse_lib_symbol_graphics(item), + } + return result + + +# --------------------------------------------------------------------------- +# Geometry helpers +# --------------------------------------------------------------------------- + + +def compute_symbol_bbox( + schematic_path: Path, + reference: str, + locator: PinLocator, +) -> Optional[Tuple[float, float, float, float]]: + """ + Compute bounding box of a symbol from its pin positions. + + Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins found. + """ + pins = locator.get_all_symbol_pins(schematic_path, reference) + if not pins: + return None + xs = [p[0] for p in pins.values()] + ys = [p[1] for p in pins.values()] + return (min(xs), min(ys), max(xs), max(ys)) + + +def _line_segment_intersects_aabb( + x1: float, + y1: float, + x2: float, + y2: float, + box_min_x: float, + box_min_y: float, + box_max_x: float, + box_max_y: float, +) -> bool: + """ + Test whether line segment (x1,y1)→(x2,y2) intersects an axis-aligned bounding box. + + Uses the Liang-Barsky clipping algorithm. + """ + dx = x2 - x1 + dy = y2 - y1 + + p = [-dx, dx, -dy, dy] + q = [x1 - box_min_x, box_max_x - x1, y1 - box_min_y, box_max_y - y1] + + t_min = 0.0 + t_max = 1.0 + + for i in range(4): + if abs(p[i]) < 1e-12: + # Parallel to this edge + if q[i] < 0: + return False + else: + t = q[i] / p[i] + if p[i] < 0: + t_min = max(t_min, t) + else: + t_max = min(t_max, t) + if t_min > t_max: + return False + + return True + + +def _point_in_rect( + px: float, + py: float, + min_x: float, + min_y: float, + max_x: float, + max_y: float, +) -> bool: + """Check if a point is within a rectangle.""" + return min_x <= px <= max_x and min_y <= py <= max_y + + +def _distance(p1: Tuple[float, float], p2: Tuple[float, float]) -> float: + """Euclidean distance between two points.""" + return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2) + + +def _aabb_overlap( + a: Tuple[float, float, float, float], + b: Tuple[float, float, float, float], +) -> bool: + """Check if two axis-aligned bounding boxes overlap. + + Each bbox is (min_x, min_y, max_x, max_y). + """ + return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3] + + +def _transform_local_point( + lx: float, + ly: float, + sym_x: float, + sym_y: float, + rotation: float, + mirror_x: bool, + mirror_y: bool, +) -> Tuple[float, float]: + """ + Transform a point from local symbol coordinates to absolute schematic + coordinates using KiCad's transform order: + negate-y (lib y-up → schematic y-down) → mirror → rotate → translate. + """ + # Library symbols use y-up; schematic uses y-down + ly = -ly + + # Apply mirroring in local coords + if mirror_x: + ly = -ly + if mirror_y: + lx = -lx + + # Apply rotation + if rotation != 0: + lx, ly = PinLocator.rotate_point(lx, ly, rotation) + + return (sym_x + lx, sym_y + ly) + + +def _compute_symbol_bbox_direct( + sym: Dict[str, Any], + pin_defs: Dict[str, Dict], + margin: float = 0.0, + graphics_points: Optional[List[Tuple[float, float]]] = None, +) -> Optional[Tuple[float, float, float, float]]: + """ + Compute bounding box of a symbol from its graphics and pin definitions. + + When graphics_points are available (from lib_symbol body shapes), uses + those for the bbox and unions with pin positions. Falls back to + pin-only estimation with degenerate expansion when no graphics data + is available. + + Args: + sym: Parsed symbol dict with x, y, rotation, mirror_x, mirror_y. + pin_defs: Pin definitions from PinLocator.get_symbol_pins(). + margin: Shrink bbox by this amount on each side (mm). + graphics_points: Local-coordinate points from symbol body graphics. + + Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins. + """ + pin_positions = _compute_pin_positions_direct(sym, pin_defs) + if not pin_positions: + return None + + if graphics_points: + # Transform graphics points to absolute coordinates + sym_x, sym_y = sym["x"], sym["y"] + rotation = sym["rotation"] + mirror_x = sym.get("mirror_x", False) + mirror_y = sym.get("mirror_y", False) + + abs_points = [ + _transform_local_point(lx, ly, sym_x, sym_y, rotation, mirror_x, mirror_y) + for lx, ly in graphics_points + ] + + # Union with pin positions so pins extending beyond body are included + all_xs = [p[0] for p in abs_points] + [p[0] for p in pin_positions.values()] + all_ys = [p[1] for p in abs_points] + [p[1] for p in pin_positions.values()] + + min_x, min_y = min(all_xs), min(all_ys) + max_x, max_y = max(all_xs), max(all_ys) + else: + # Fallback: pin-only estimation with degenerate expansion + xs = [p[0] for p in pin_positions.values()] + ys = [p[1] for p in pin_positions.values()] + min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys) + + min_body = 1.5 # mm minimum half-extent for component body + if max_x - min_x < 2 * min_body: + cx = (min_x + max_x) / 2 + min_x = cx - min_body + max_x = cx + min_body + if max_y - min_y < 2 * min_body: + cy = (min_y + max_y) / 2 + min_y = cy - min_body + max_y = cy + min_body + + # Shrink bbox by margin + min_x += margin + min_y += margin + max_x -= margin + max_y -= margin + + # Skip degenerate bboxes + if max_x <= min_x or max_y <= min_y: + return None + + return (min_x, min_y, max_x, max_y) + + +# --------------------------------------------------------------------------- +# Tool 3: find_overlapping_elements +# --------------------------------------------------------------------------- + + +def find_overlapping_elements(schematic_path: Path, tolerance: float = 0.5) -> Dict[str, Any]: + """ + Detect spatially overlapping symbols, wires, and labels. + + Args: + schematic_path: Path to .kicad_sch file + tolerance: Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. + + Returns dict: {overlappingSymbols, overlappingLabels, overlappingWires, totalOverlaps} + """ + sexp_data = _load_sexp(schematic_path) + symbols = _parse_symbols(sexp_data) + wires = _parse_wires(sexp_data) + labels = _parse_labels(sexp_data) + + overlapping_symbols = [] + overlapping_labels = [] + overlapping_wires = [] + + lib_defs = _extract_lib_symbols(sexp_data) + + # --- Symbol-symbol overlap using bounding-box intersection (O(n²)) --- + non_template_symbols = [ + s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"] + ] + + # Pre-compute bounding boxes for all non-template symbols + symbol_bboxes = [] + for sym in non_template_symbols: + lib_data = lib_defs.get(sym["lib_id"], {}) + pin_defs = lib_data.get("pins", {}) + graphics_points = lib_data.get("graphics_points", []) + bbox = None + if pin_defs: + bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points) + symbol_bboxes.append((sym, bbox)) + + for i in range(len(symbol_bboxes)): + s1, bbox1 = symbol_bboxes[i] + for j in range(i + 1, len(symbol_bboxes)): + s2, bbox2 = symbol_bboxes[j] + dist = _distance((s1["x"], s1["y"]), (s2["x"], s2["y"])) + + overlap_detected = False + if bbox1 is not None and bbox2 is not None: + # Use bounding box intersection + overlap_detected = _aabb_overlap(bbox1, bbox2) + else: + # Fallback to center distance when pin data is unavailable + overlap_detected = dist < tolerance + + if overlap_detected: + entry = { + "element1": { + "reference": s1["reference"], + "libId": s1["lib_id"], + "position": {"x": s1["x"], "y": s1["y"]}, + }, + "element2": { + "reference": s2["reference"], + "libId": s2["lib_id"], + "position": {"x": s2["x"], "y": s2["y"]}, + }, + "distance": round(dist, 4), + } + # Flag power symbol pairs specifically + if s1["is_power"] and s2["is_power"]: + entry["type"] = "power_symbol_overlap" + else: + entry["type"] = "symbol_overlap" + overlapping_symbols.append(entry) + + # --- Label-label overlap --- + for i in range(len(labels)): + for j in range(i + 1, len(labels)): + l1 = labels[i] + l2 = labels[j] + dist = _distance((l1["x"], l1["y"]), (l2["x"], l2["y"])) + if dist < tolerance: + overlapping_labels.append( + { + "element1": { + "name": l1["name"], + "type": l1["type"], + "position": {"x": l1["x"], "y": l1["y"]}, + }, + "element2": { + "name": l2["name"], + "type": l2["type"], + "position": {"x": l2["x"], "y": l2["y"]}, + }, + "distance": round(dist, 4), + } + ) + + # --- Wire-wire collinear overlap --- + for i in range(len(wires)): + for j in range(i + 1, len(wires)): + w1 = wires[i] + w2 = wires[j] + overlap = _check_wire_overlap(w1, w2, tolerance) + if overlap: + overlapping_wires.append(overlap) + + total = len(overlapping_symbols) + len(overlapping_labels) + len(overlapping_wires) + + return { + "overlappingSymbols": overlapping_symbols, + "overlappingLabels": overlapping_labels, + "overlappingWires": overlapping_wires, + "totalOverlaps": total, + } + + +def _check_wire_overlap( + w1: Dict[str, Any], w2: Dict[str, Any], tolerance: float +) -> Optional[Dict[str, Any]]: + """ + Check if two wire segments are collinear and overlapping. + + Works for horizontal, vertical, and diagonal wires. Uses direction + vectors, cross-product parallelism, point-to-line distance for + collinearity, and 1D projection overlap. + + Returns overlap info dict or None. + """ + s1, e1 = w1["start"], w1["end"] + s2, e2 = w2["start"], w2["end"] + + d1 = (e1[0] - s1[0], e1[1] - s1[1]) + d2 = (e2[0] - s2[0], e2[1] - s2[1]) + + len1 = math.sqrt(d1[0] ** 2 + d1[1] ** 2) + len2 = math.sqrt(d2[0] ** 2 + d2[1] ** 2) + if len1 < 1e-12 or len2 < 1e-12: + return None # degenerate zero-length segment + + # Cross product to check parallel + cross = d1[0] * d2[1] - d1[1] * d2[0] + if abs(cross) > tolerance * max(len1, len2): + return None # not parallel + + # Point-to-line distance: s2 relative to line through s1 along d1 + ds = (s2[0] - s1[0], s2[1] - s1[1]) + perp_dist = abs(ds[0] * d1[1] - ds[1] * d1[0]) / len1 + if perp_dist > tolerance: + return None # parallel but offset + + # Project onto d1 direction for 1D overlap check + u1 = (d1[0] / len1, d1[1] / len1) + proj_s1 = s1[0] * u1[0] + s1[1] * u1[1] + proj_e1 = e1[0] * u1[0] + e1[1] * u1[1] + proj_s2 = s2[0] * u1[0] + s2[1] * u1[1] + proj_e2 = e2[0] * u1[0] + e2[1] * u1[1] + + min1, max1 = min(proj_s1, proj_e1), max(proj_s1, proj_e1) + min2, max2 = min(proj_s2, proj_e2), max(proj_s2, proj_e2) + if min1 < max2 and min2 < max1: + return { + "wire1": { + "start": {"x": s1[0], "y": s1[1]}, + "end": {"x": e1[0], "y": e1[1]}, + }, + "wire2": { + "start": {"x": s2[0], "y": s2[1]}, + "end": {"x": e2[0], "y": e2[1]}, + }, + "type": "collinear_overlap", + } + + return None + + +# --------------------------------------------------------------------------- +# Tool 4: get_elements_in_region +# --------------------------------------------------------------------------- + + +def get_elements_in_region( + schematic_path: Path, + x1: float, + y1: float, + x2: float, + y2: float, +) -> Dict[str, Any]: + """ + List all wires, labels, and symbols within a rectangular region. + + Args: + schematic_path: Path to .kicad_sch file + x1, y1, x2, y2: Bounding box corners in schematic mm + + Returns dict: {symbols, wires, labels, counts} + """ + min_x, max_x = min(x1, x2), max(x1, x2) + min_y, max_y = min(y1, y2), max(y1, y2) + + sexp_data = _load_sexp(schematic_path) + symbols = _parse_symbols(sexp_data) + wires = _parse_wires(sexp_data) + labels = _parse_labels(sexp_data) + + lib_defs = _extract_lib_symbols(sexp_data) + + # Symbols: include if position is within bounds + region_symbols = [] + for sym in symbols: + if not sym["reference"] or sym["reference"].startswith("_TEMPLATE"): + continue + if _point_in_rect(sym["x"], sym["y"], min_x, min_y, max_x, max_y): + entry = { + "reference": sym["reference"], + "libId": sym["lib_id"], + "position": {"x": sym["x"], "y": sym["y"]}, + "isPower": sym["is_power"], + } + # Include pin positions (compute directly to handle unannotated duplicates) + lib_data = lib_defs.get(sym["lib_id"], {}) + pin_defs = lib_data.get("pins", {}) + if pin_defs: + pin_positions = _compute_pin_positions_direct(sym, pin_defs) + if pin_positions: + entry["pins"] = { + pn: {"x": round(pos[0], 4), "y": round(pos[1], 4)} + for pn, pos in pin_positions.items() + } + region_symbols.append(entry) + + # Wires: include if any part of the wire intersects the region + region_wires = [] + for w in wires: + s, e = w["start"], w["end"] + if ( + _point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y) + or _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y) + or _line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y) + ): + region_wires.append( + { + "start": {"x": s[0], "y": s[1]}, + "end": {"x": e[0], "y": e[1]}, + } + ) + + # Labels: include if position is within bounds + region_labels = [] + for lbl in labels: + if _point_in_rect(lbl["x"], lbl["y"], min_x, min_y, max_x, max_y): + region_labels.append( + { + "name": lbl["name"], + "type": lbl["type"], + "position": {"x": lbl["x"], "y": lbl["y"]}, + } + ) + + return { + "symbols": region_symbols, + "wires": region_wires, + "labels": region_labels, + "counts": { + "symbols": len(region_symbols), + "wires": len(region_wires), + "labels": len(region_labels), + }, + } + + +# --------------------------------------------------------------------------- +# Tool 5: check_wire_collisions +# --------------------------------------------------------------------------- + + +def _compute_pin_positions_direct( + sym: Dict[str, Any], pin_defs: Dict[str, Dict] +) -> Dict[str, List[float]]: + """ + Compute absolute schematic pin positions for a symbol instance directly from + its parsed position/rotation/mirror data and pin definitions in local coords. + + Unlike PinLocator.get_all_symbol_pins, this does NOT do a reference-name + lookup in the schematic, so it works correctly when multiple symbols share + the same reference designator (e.g. unannotated "Q?"). + + KiCad transform order: mirror (in local coords) → rotate → translate. + """ + sym_x = sym["x"] + sym_y = sym["y"] + rotation = sym["rotation"] + mirror_x = sym.get("mirror_x", False) + mirror_y = sym.get("mirror_y", False) + + result: Dict[str, List[float]] = {} + for pin_num, pin_data in pin_defs.items(): + rel_x = float(pin_data["x"]) + rel_y = float(pin_data["y"]) + + # Apply mirroring in local symbol coordinates + if mirror_x: + rel_y = -rel_y + if mirror_y: + rel_x = -rel_x + + # Apply symbol rotation + if rotation != 0: + rel_x, rel_y = PinLocator.rotate_point(rel_x, rel_y, rotation) + + result[pin_num] = [sym_x + rel_x, sym_y + rel_y] + return result + + +def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]: + """ + Find all wires that cross over component symbol bodies. + + Wires passing over symbols are unacceptable in schematics — they indicate + routing mistakes where a wire was drawn across a component instead of + around it. + + For each non-power, non-template symbol: + 1. Compute bounding box from pin positions (shrunk by margin). + 2. For each wire segment, test intersection with the bbox. + 3. If intersects and the wire is not simply terminating at a pin from + outside, report it as a crossing. + + Returns list of crossing dicts. + """ + sexp_data = _load_sexp(schematic_path) + symbols = _parse_symbols(sexp_data) + wires = _parse_wires(sexp_data) + + lib_defs = _extract_lib_symbols(sexp_data) + margin = 0.5 # mm margin to shrink bbox (avoids false positives at pin tips) + pin_tolerance = 0.05 # mm + + collisions = [] + + # Pre-compute per-symbol data + symbol_data: List[Dict[str, Any]] = [] + for sym in symbols: + ref = sym["reference"] + if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref: + continue + + lib_data = lib_defs.get(sym["lib_id"], {}) + pin_defs = lib_data.get("pins", {}) + if not pin_defs: + continue + + graphics_points = lib_data.get("graphics_points", []) + bbox = _compute_symbol_bbox_direct( + sym, pin_defs, margin=margin, graphics_points=graphics_points + ) + if bbox is None: + continue + + pin_positions = _compute_pin_positions_direct(sym, pin_defs) + pin_set = set() + for pos in pin_positions.values(): + pin_set.add((pos[0], pos[1])) + + symbol_data.append( + { + "sym": sym, + "bbox": bbox, + "pin_set": pin_set, + } + ) + + # Test each wire against each symbol bbox + for w in wires: + sx, sy = w["start"] + ex, ey = w["end"] + + for sd in symbol_data: + bx1, by1, bx2, by2 = sd["bbox"] + + if not _line_segment_intersects_aabb(sx, sy, ex, ey, bx1, by1, bx2, by2): + continue + + # Check which endpoints land on a pin of this symbol + start_at_pin = any( + abs(sx - px) < pin_tolerance and abs(sy - py) < pin_tolerance + for px, py in sd["pin_set"] + ) + end_at_pin = any( + abs(ex - px) < pin_tolerance and abs(ey - py) < pin_tolerance + for px, py in sd["pin_set"] + ) + + # When exactly one endpoint is at a pin, check whether the wire + # just terminates at the pin (valid connection) or continues through + # the component body (pass-through → collision). + # Nudge the pin endpoint slightly toward the other end; if the + # shortened segment still intersects the bbox, the wire extends + # into/through the body. + if (start_at_pin or end_at_pin) and not (start_at_pin and end_at_pin): + dx, dy = ex - sx, ey - sy + length = math.sqrt(dx * dx + dy * dy) + if length > 0: + nudge = min(0.2, length * 0.5) + ux, uy = dx / length, dy / length + if start_at_pin: + nsx, nsy = sx + ux * nudge, sy + uy * nudge + if not _line_segment_intersects_aabb(nsx, nsy, ex, ey, bx1, by1, bx2, by2): + continue # Wire terminates at pin from outside + else: + nex, ney = ex - ux * nudge, ey - uy * nudge + if not _line_segment_intersects_aabb(sx, sy, nex, ney, bx1, by1, bx2, by2): + continue # Wire terminates at pin from outside + + sym = sd["sym"] + collisions.append( + { + "wire": { + "start": {"x": sx, "y": sy}, + "end": {"x": ex, "y": ey}, + }, + "component": { + "reference": sym["reference"], + "libId": sym["lib_id"], + "position": {"x": sym["x"], "y": sym["y"]}, + }, + "intersectionType": "passes_through", + } + ) + + return collisions + + +def find_orphaned_wires(schematic_path: Path) -> Dict[str, Any]: + """ + Find wire segments with at least one dangling endpoint. + + A wire endpoint is dangling when the IU point at that endpoint satisfies + all three conditions simultaneously: + 1. No other wire shares that IU endpoint (would imply a junction / T-join) + 2. No component pin is at that IU point + 3. No net label or power symbol pin is at that IU point + + Uses exact KiCad IU matching (10 000 IU/mm) — same strategy as + wire_connectivity.py — to avoid floating-point tolerance issues. + + Returns: + { + "orphaned_wires": [ + { + "start": {"x": float, "y": float}, + "end": {"x": float, "y": float}, + "dangling_ends": [{"x": float, "y": float}, ...] + }, + ... + ], + "count": int + } + """ + sexp_data = _load_sexp(schematic_path) + + # --- wire endpoints in mm and IU --- + wires_mm = _parse_wires(sexp_data) + wires_iu: List[Tuple[Tuple[int, int], Tuple[int, int]]] = [ + (_to_iu(*w["start"]), _to_iu(*w["end"])) for w in wires_mm + ] + + # Count how many wires touch each IU endpoint + iu_to_count: Dict[Tuple[int, int], int] = defaultdict(int) + for s_iu, e_iu in wires_iu: + iu_to_count[s_iu] += 1 + iu_to_count[e_iu] += 1 + + # --- anchors: component pins --- + pin_iu: Set[Tuple[int, int]] = set() + try: + locator = PinLocator() + sch = Schematic(str(schematic_path)) + for symbol in sch.symbol: + try: + if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + all_pins = locator.get_all_symbol_pins(schematic_path, ref) + for coords in all_pins.values(): + pin_iu.add(_to_iu(float(coords[0]), float(coords[1]))) + except Exception as e: + logger.warning(f"Error reading pins for symbol: {e}") + except Exception as e: + logger.warning(f"Could not load schematic via skip for pin extraction: {e}") + sch = None + + # --- anchors: net labels and global_labels --- + labels = _parse_labels(sexp_data) + label_iu: Set[Tuple[int, int]] = {_to_iu(lbl["x"], lbl["y"]) for lbl in labels} + + # --- anchors: power symbol pins (VCC, GND …) --- + power_iu: Set[Tuple[int, int]] = set() + if sch is not None: + try: + point_to_label, _ = _parse_virtual_connections(sch, schematic_path) + power_iu = set(point_to_label.keys()) + except Exception as e: + logger.warning(f"Could not extract power symbol anchors: {e}") + + anchored_iu = pin_iu | label_iu | power_iu + + # --- classify each wire --- + orphaned: List[Dict[str, Any]] = [] + for i, (s_iu, e_iu) in enumerate(wires_iu): + w = wires_mm[i] + dangling_ends: List[Dict[str, float]] = [] + for pt_iu, pt_mm in [(s_iu, w["start"]), (e_iu, w["end"])]: + if iu_to_count[pt_iu] > 1: + continue # shared with another wire → connected + if pt_iu in anchored_iu: + continue # touches a pin or label → connected + dangling_ends.append({"x": pt_mm[0], "y": pt_mm[1]}) + if dangling_ends: + orphaned.append( + { + "start": {"x": w["start"][0], "y": w["start"][1]}, + "end": {"x": w["end"][0], "y": w["end"][1]}, + "dangling_ends": dangling_ends, + } + ) + + return {"orphaned_wires": orphaned, "count": len(orphaned)} diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index b6efb2f..b488fc1 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -1,494 +1,494 @@ -""" -Wire Connectivity Analysis for KiCad Schematics - -Traces wire networks from a point and finds connected component pins. -Uses KiCad's internal integer unit system (10,000 IU per mm) for exact -coordinate matching, mirroring KiCad's own connectivity algorithm. -""" - -import logging -from pathlib import Path -from typing import Any, Dict, List, Optional, Set, Tuple - -from commands.pin_locator import PinLocator - -logger = logging.getLogger("kicad_interface") - -_IU_PER_MM = 10000 # KiCad schematic internal units per millimeter - - -def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]: - """Convert mm coordinates to KiCad internal units (integer).""" - return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM)) - - -def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]: - """Extract wire endpoints from a schematic object as IU tuples.""" - all_wires = [] - for wire in schematic.wire: - if hasattr(wire, "pts") and hasattr(wire.pts, "xy"): - pts = [] - for point in wire.pts.xy: - if hasattr(point, "value"): - pts.append(_to_iu(float(point.value[0]), float(point.value[1]))) - if len(pts) >= 2: - all_wires.append(pts) - return all_wires - - -def _build_adjacency( - all_wires: List[List[Tuple[int, int]]], -) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]: - """Build wire adjacency using exact IU coordinate matching. - - Wires that share an endpoint are adjacent — this naturally handles - junctions since all wires meeting at the same point get connected. - - Returns a tuple of: - - adjacency: list of sets, one per wire, containing adjacent wire indices - - iu_to_wires: dict mapping each IU endpoint to the set of wire indices - that have an endpoint at that exact coordinate (used for seed queries) - """ - # Map each IU endpoint to all wire indices that touch it - iu_to_wires: Dict[Tuple[int, int], Set[int]] = {} - for i, pts in enumerate(all_wires): - for pt in pts: - iu_to_wires.setdefault(pt, set()).add(i) - - # Wires that share an IU endpoint are adjacent - adjacency: List[Set[int]] = [set() for _ in range(len(all_wires))] - for wire_set in iu_to_wires.values(): - wire_list = list(wire_set) - for a in wire_list: - for b in wire_list: - if a != b: - adjacency[a].add(b) - - return adjacency, iu_to_wires - - -def _parse_virtual_connections( - schematic: Any, schematic_path: Any -) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]: - """Return virtual connectivity from net labels and power symbols. - - Returns a tuple of: - - point_to_label: Dict[Tuple[int,int], str] — IU position → label name - - label_to_points: Dict[str, List[Tuple[int,int]]] — label name → list of IU positions - """ - point_to_label: Dict[Tuple[int, int], str] = {} - label_to_points: Dict[str, List[Tuple[int, int]]] = {} - - if hasattr(schematic, "label"): - for label in schematic.label: - try: - if not hasattr(label, "value"): - continue - name = label.value - if not hasattr(label, "at") or not hasattr(label.at, "value"): - continue - coords = label.at.value - pt = _to_iu(float(coords[0]), float(coords[1])) - point_to_label[pt] = name - label_to_points.setdefault(name, []).append(pt) - except Exception as e: - logger.warning(f"Error parsing net label: {e}") - - if hasattr(schematic, "symbol"): - locator = PinLocator() - for symbol in schematic.symbol: - try: - if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): - continue - ref = symbol.property.Reference.value - if not ref.startswith("#PWR"): - continue - if ref.startswith("_TEMPLATE"): - continue - if not hasattr(symbol.property, "Value"): - continue - name = symbol.property.Value.value - all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref) - if not all_pins or "1" not in all_pins: - continue - pin_data = all_pins["1"] - pt = _to_iu(float(pin_data[0]), float(pin_data[1])) - point_to_label[pt] = name - label_to_points.setdefault(name, []).append(pt) - except Exception as e: - logger.warning(f"Error parsing power symbol: {e}") - - return point_to_label, label_to_points - - -def _find_connected_wires( - x_mm: float, - y_mm: float, - all_wires: List[List[Tuple[int, int]]], - iu_to_wires: Dict[Tuple[int, int], Set[int]], - adjacency: List[Set[int]], - point_to_label: Optional[Dict[Tuple[int, int], str]] = None, - label_to_points: Optional[Dict[str, List[Tuple[int, int]]]] = None, -) -> Tuple: - """BFS from query point. Returns (visited wire indices, net IU points) or (None, None). - - Requires query point (x_mm, y_mm) to be exactly on a wire endpoint (exact IU match). - """ - query_iu = _to_iu(x_mm, y_mm) - - # Find seed wires: exact IU match on the query endpoint - seed_set = iu_to_wires.get(query_iu) - if not seed_set: - return (None, None) - seed_indices: Set[int] = set(seed_set) - - # BFS flood-fill using pre-compiled adjacency - visited: Set[int] = set(seed_indices) - queue = list(seed_indices) - net_points: Set[Tuple[int, int]] = set() - for i in seed_indices: - net_points.update(all_wires[i]) - - seen_labels: Set[str] = set() - while queue: - wire_idx = queue.pop() - for neighbor_idx in adjacency[wire_idx]: - if neighbor_idx not in visited: - visited.add(neighbor_idx) - queue.append(neighbor_idx) - net_points.update(all_wires[neighbor_idx]) - - if point_to_label and label_to_points: - for pt in all_wires[wire_idx]: - label_name = point_to_label.get(pt) - if label_name and label_name not in seen_labels: - seen_labels.add(label_name) - for other_pt in label_to_points.get(label_name, []): - if other_pt == pt: - continue - for idx in iu_to_wires.get(other_pt, set()): - if idx not in visited: - visited.add(idx) - queue.append(idx) - net_points.update(all_wires[idx]) - - return (visited, net_points) - - -def _find_pins_on_net( - net_points: Set[Tuple[int, int]], - schematic_path: Any, - schematic: Any, -) -> List[Dict]: - """Find component pins that land on net points using exact IU matching. - - Returns a list of {"component": ref, "pin": pin_num} dicts. - """ - - def _on_net(px_mm: float, py_mm: float) -> bool: - return _to_iu(px_mm, py_mm) in net_points - - locator = PinLocator() - pins = [] - seen: Set[Tuple] = set() - - ref = None - for symbol in schematic.symbol: - try: - if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): - continue - ref = symbol.property.Reference.value - if ref.startswith("_TEMPLATE"): - continue - all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref) - if not all_pins: - continue - for pin_num, pin_data in all_pins.items(): - if _on_net(pin_data[0], pin_data[1]): - key = (ref, pin_num) - if key not in seen: - seen.add(key) - pins.append({"component": ref, "pin": pin_num}) - except Exception as e: - logger.warning( - f"Error checking pins for {ref if ref is not None else ''}: {e}" - ) - - return pins - - -def get_wire_connections( - schematic: Any, schematic_path: str, x_mm: float, y_mm: float -) -> Optional[Dict]: - """Find the net name and all component pins reachable from a point via connected wires. - - The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match). - Interior (mid-segment) points are not matched — - use wire endpoint coordinates obtained from the schematic data. - - Net labels and power symbols are traversed: wires on the same named net are - treated as connected even when they are not geometrically adjacent. - - Returns dict with keys: - - "net": str or None (net label/power name, None if unnamed) - - "pins": list of {"component": str, "pin": str} - - "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm - - "query_point": {"x": float, "y": float} - Or None if no wire endpoint found within tolerance of the query point. - """ - all_wires = _parse_wires(schematic) - query_point = {"x": x_mm, "y": y_mm} - if not all_wires: - return {"net": None, "pins": [], "wires": [], "query_point": query_point} - - adjacency, iu_to_wires = _build_adjacency(all_wires) - - point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) - - visited, net_points = _find_connected_wires( - x_mm, - y_mm, - all_wires, - iu_to_wires, - adjacency, - point_to_label=point_to_label, - label_to_points=label_to_points, - ) - if visited is None: - return None - - # Resolve net name: first label anchor that falls on this net's IU points - net: Optional[str] = None - for pt in net_points: - label = point_to_label.get(pt) - if label is not None: - net = label - break - - wires_out = [ - { - "start": { - "x": all_wires[i][0][0] / _IU_PER_MM, - "y": all_wires[i][0][1] / _IU_PER_MM, - }, - "end": { - "x": all_wires[i][-1][0] / _IU_PER_MM, - "y": all_wires[i][-1][1] / _IU_PER_MM, - }, - } - for i in visited - ] - - if not hasattr(schematic, "symbol"): - return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point} - - pins = _find_pins_on_net(net_points, schematic_path, schematic) - return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point} - - -def count_pins_on_net( - schematic: Any, - schematic_path: str, - net_name: str, - all_wires: List[List[Tuple[int, int]]], - iu_to_wires: Dict[Tuple[int, int], Set[int]], - adjacency: List[Set[int]], - point_to_label: Dict[Tuple[int, int], str], - label_to_points: Dict[str, List[Tuple[int, int]]], -) -> int: - """Count the number of component pins connected to the named net. - - A pin is counted if its IU coordinate falls on the wire-network reachable - from any label anchor for *net_name*, or directly on a label anchor of that - net (pin directly touching a label with no intervening wire). - - Returns the count of distinct (component, pin_num) pairs on this net. - """ - label_positions = label_to_points.get(net_name, []) - if not label_positions: - return 0 - - # Collect the union of all net-points across all label positions for this net - all_net_points: Set[Tuple[int, int]] = set() - for lx, ly in label_positions: - # Include the label anchor itself so pins directly at the label count - all_net_points.add((lx, ly)) - # Trace from this label position into the wire graph - x_mm = lx / _IU_PER_MM - y_mm = ly / _IU_PER_MM - visited, net_points = _find_connected_wires( - x_mm, - y_mm, - all_wires, - iu_to_wires, - adjacency, - point_to_label=point_to_label, - label_to_points=label_to_points, - ) - if net_points: - all_net_points |= net_points - - if not hasattr(schematic, "symbol"): - return 0 - - locator = PinLocator() - seen: Set[Tuple[str, str]] = set() - ref = None - for symbol in schematic.symbol: - try: - if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): - continue - ref = symbol.property.Reference.value - if ref.startswith("_TEMPLATE"): - continue - all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref) - if not all_pins: - continue - for pin_num, pin_data in all_pins.items(): - pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1])) - if pin_iu in all_net_points: - key = (ref, pin_num) - if key not in seen: - seen.add(key) - except Exception as e: - logger.warning( - f"Error checking pins for {ref if ref is not None else ''}: {e}" - ) - - return len(seen) - - -def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]: - """Return net labels that are not connected to any component pin. - - A label is "floating" when no component pin's IU coordinate falls on the - wire-network reachable from the label's anchor position. These labels are - likely placed off-grid or incorrectly positioned and will cause ERC errors. - - Returns a list of dicts with keys: - - "name": str — the net label text - - "x": float — label X position in mm - - "y": float — label Y position in mm - - "type": str — "label" or "global_label" - """ - all_wires = _parse_wires(schematic) - if all_wires: - adjacency, iu_to_wires = _build_adjacency(all_wires) - else: - adjacency = [] - iu_to_wires = {} - - point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) - - # Build a set of all pin IU positions for fast lookup - pin_iu_set: Set[Tuple[int, int]] = set() - if hasattr(schematic, "symbol"): - locator = PinLocator() - for symbol in schematic.symbol: - try: - if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): - continue - ref = symbol.property.Reference.value - if ref.startswith("_TEMPLATE"): - continue - all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref) - if not all_pins: - continue - for pin_data in all_pins.values(): - pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1]))) - except Exception as e: - logger.warning(f"Error reading pins for floating-label check: {e}") - - floating: List[Dict[str, Any]] = [] - - if not hasattr(schematic, "label"): - return floating - - for label in schematic.label: - try: - if not hasattr(label, "value"): - continue - name = label.value - if not hasattr(label, "at") or not hasattr(label.at, "value"): - continue - coords = label.at.value - lx_mm = float(coords[0]) - ly_mm = float(coords[1]) - label_iu = _to_iu(lx_mm, ly_mm) - - # Check if the label anchor itself is a pin position - if label_iu in pin_iu_set: - continue - - # Trace the wire-network from this label and check for pins - if all_wires: - _, net_points = _find_connected_wires( - lx_mm, - ly_mm, - all_wires, - iu_to_wires, - adjacency, - point_to_label=point_to_label, - label_to_points=label_to_points, - ) - else: - net_points = None - - if net_points is not None and net_points & pin_iu_set: - continue # at least one pin on this net - - floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"}) - - except Exception as e: - logger.warning(f"Error checking label for floating status: {e}") - - return floating - - -def get_net_at_point( - schematic: Any, schematic_path: str, x_mm: float, y_mm: float -) -> Dict[str, Any]: - """Return the net name at the given coordinate, or null if none found. - - Checks net label positions first (exact IU match within tolerance), then - wire endpoints. Returns a dict with keys: - - "net_name": str or None - - "position": {"x": float, "y": float} - - "source": "net_label" | "wire_endpoint" | None - """ - query_iu = _to_iu(x_mm, y_mm) - position = {"x": x_mm, "y": y_mm} - - # Build label map from schematic - point_to_label, _ = _parse_virtual_connections(schematic, schematic_path) - - # Check if query point is exactly on a net label / power symbol position - label_name = point_to_label.get(query_iu) - if label_name is not None: - return {"net_name": label_name, "position": position, "source": "net_label"} - - # Check if query point is on a wire endpoint - all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else [] - if all_wires: - adjacency, iu_to_wires = _build_adjacency(all_wires) - if query_iu in iu_to_wires: - # Found a wire endpoint — trace the net to get the name - visited, net_points = _find_connected_wires( - x_mm, - y_mm, - all_wires, - iu_to_wires, - adjacency, - point_to_label=point_to_label, - label_to_points=None, - ) - if visited is not None: - net: Optional[str] = None - if net_points: - for pt in net_points: - net = point_to_label.get(pt) - if net is not None: - break - return {"net_name": net, "position": position, "source": "wire_endpoint"} - - return {"net_name": None, "position": position, "source": None} +""" +Wire Connectivity Analysis for KiCad Schematics + +Traces wire networks from a point and finds connected component pins. +Uses KiCad's internal integer unit system (10,000 IU per mm) for exact +coordinate matching, mirroring KiCad's own connectivity algorithm. +""" + +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +from commands.pin_locator import PinLocator + +logger = logging.getLogger("kicad_interface") + +_IU_PER_MM = 10000 # KiCad schematic internal units per millimeter + + +def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]: + """Convert mm coordinates to KiCad internal units (integer).""" + return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM)) + + +def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]: + """Extract wire endpoints from a schematic object as IU tuples.""" + all_wires = [] + for wire in schematic.wire: + if hasattr(wire, "pts") and hasattr(wire.pts, "xy"): + pts = [] + for point in wire.pts.xy: + if hasattr(point, "value"): + pts.append(_to_iu(float(point.value[0]), float(point.value[1]))) + if len(pts) >= 2: + all_wires.append(pts) + return all_wires + + +def _build_adjacency( + all_wires: List[List[Tuple[int, int]]], +) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]: + """Build wire adjacency using exact IU coordinate matching. + + Wires that share an endpoint are adjacent — this naturally handles + junctions since all wires meeting at the same point get connected. + + Returns a tuple of: + - adjacency: list of sets, one per wire, containing adjacent wire indices + - iu_to_wires: dict mapping each IU endpoint to the set of wire indices + that have an endpoint at that exact coordinate (used for seed queries) + """ + # Map each IU endpoint to all wire indices that touch it + iu_to_wires: Dict[Tuple[int, int], Set[int]] = {} + for i, pts in enumerate(all_wires): + for pt in pts: + iu_to_wires.setdefault(pt, set()).add(i) + + # Wires that share an IU endpoint are adjacent + adjacency: List[Set[int]] = [set() for _ in range(len(all_wires))] + for wire_set in iu_to_wires.values(): + wire_list = list(wire_set) + for a in wire_list: + for b in wire_list: + if a != b: + adjacency[a].add(b) + + return adjacency, iu_to_wires + + +def _parse_virtual_connections( + schematic: Any, schematic_path: Any +) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]: + """Return virtual connectivity from net labels and power symbols. + + Returns a tuple of: + - point_to_label: Dict[Tuple[int,int], str] — IU position → label name + - label_to_points: Dict[str, List[Tuple[int,int]]] — label name → list of IU positions + """ + point_to_label: Dict[Tuple[int, int], str] = {} + label_to_points: Dict[str, List[Tuple[int, int]]] = {} + + if hasattr(schematic, "label"): + for label in schematic.label: + try: + if not hasattr(label, "value"): + continue + name = label.value + if not hasattr(label, "at") or not hasattr(label.at, "value"): + continue + coords = label.at.value + pt = _to_iu(float(coords[0]), float(coords[1])) + point_to_label[pt] = name + label_to_points.setdefault(name, []).append(pt) + except Exception as e: + logger.warning(f"Error parsing net label: {e}") + + if hasattr(schematic, "symbol"): + locator = PinLocator() + for symbol in schematic.symbol: + try: + if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if not ref.startswith("#PWR"): + continue + if ref.startswith("_TEMPLATE"): + continue + if not hasattr(symbol.property, "Value"): + continue + name = symbol.property.Value.value + all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref) + if not all_pins or "1" not in all_pins: + continue + pin_data = all_pins["1"] + pt = _to_iu(float(pin_data[0]), float(pin_data[1])) + point_to_label[pt] = name + label_to_points.setdefault(name, []).append(pt) + except Exception as e: + logger.warning(f"Error parsing power symbol: {e}") + + return point_to_label, label_to_points + + +def _find_connected_wires( + x_mm: float, + y_mm: float, + all_wires: List[List[Tuple[int, int]]], + iu_to_wires: Dict[Tuple[int, int], Set[int]], + adjacency: List[Set[int]], + point_to_label: Optional[Dict[Tuple[int, int], str]] = None, + label_to_points: Optional[Dict[str, List[Tuple[int, int]]]] = None, +) -> Tuple: + """BFS from query point. Returns (visited wire indices, net IU points) or (None, None). + + Requires query point (x_mm, y_mm) to be exactly on a wire endpoint (exact IU match). + """ + query_iu = _to_iu(x_mm, y_mm) + + # Find seed wires: exact IU match on the query endpoint + seed_set = iu_to_wires.get(query_iu) + if not seed_set: + return (None, None) + seed_indices: Set[int] = set(seed_set) + + # BFS flood-fill using pre-compiled adjacency + visited: Set[int] = set(seed_indices) + queue = list(seed_indices) + net_points: Set[Tuple[int, int]] = set() + for i in seed_indices: + net_points.update(all_wires[i]) + + seen_labels: Set[str] = set() + while queue: + wire_idx = queue.pop() + for neighbor_idx in adjacency[wire_idx]: + if neighbor_idx not in visited: + visited.add(neighbor_idx) + queue.append(neighbor_idx) + net_points.update(all_wires[neighbor_idx]) + + if point_to_label and label_to_points: + for pt in all_wires[wire_idx]: + label_name = point_to_label.get(pt) + if label_name and label_name not in seen_labels: + seen_labels.add(label_name) + for other_pt in label_to_points.get(label_name, []): + if other_pt == pt: + continue + for idx in iu_to_wires.get(other_pt, set()): + if idx not in visited: + visited.add(idx) + queue.append(idx) + net_points.update(all_wires[idx]) + + return (visited, net_points) + + +def _find_pins_on_net( + net_points: Set[Tuple[int, int]], + schematic_path: Any, + schematic: Any, +) -> List[Dict]: + """Find component pins that land on net points using exact IU matching. + + Returns a list of {"component": ref, "pin": pin_num} dicts. + """ + + def _on_net(px_mm: float, py_mm: float) -> bool: + return _to_iu(px_mm, py_mm) in net_points + + locator = PinLocator() + pins = [] + seen: Set[Tuple] = set() + + ref = None + for symbol in schematic.symbol: + try: + if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref) + if not all_pins: + continue + for pin_num, pin_data in all_pins.items(): + if _on_net(pin_data[0], pin_data[1]): + key = (ref, pin_num) + if key not in seen: + seen.add(key) + pins.append({"component": ref, "pin": pin_num}) + except Exception as e: + logger.warning( + f"Error checking pins for {ref if ref is not None else ''}: {e}" + ) + + return pins + + +def get_wire_connections( + schematic: Any, schematic_path: str, x_mm: float, y_mm: float +) -> Optional[Dict]: + """Find the net name and all component pins reachable from a point via connected wires. + + The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match). + Interior (mid-segment) points are not matched — + use wire endpoint coordinates obtained from the schematic data. + + Net labels and power symbols are traversed: wires on the same named net are + treated as connected even when they are not geometrically adjacent. + + Returns dict with keys: + - "net": str or None (net label/power name, None if unnamed) + - "pins": list of {"component": str, "pin": str} + - "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm + - "query_point": {"x": float, "y": float} + Or None if no wire endpoint found within tolerance of the query point. + """ + all_wires = _parse_wires(schematic) + query_point = {"x": x_mm, "y": y_mm} + if not all_wires: + return {"net": None, "pins": [], "wires": [], "query_point": query_point} + + adjacency, iu_to_wires = _build_adjacency(all_wires) + + point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) + + visited, net_points = _find_connected_wires( + x_mm, + y_mm, + all_wires, + iu_to_wires, + adjacency, + point_to_label=point_to_label, + label_to_points=label_to_points, + ) + if visited is None: + return None + + # Resolve net name: first label anchor that falls on this net's IU points + net: Optional[str] = None + for pt in net_points: + label = point_to_label.get(pt) + if label is not None: + net = label + break + + wires_out = [ + { + "start": { + "x": all_wires[i][0][0] / _IU_PER_MM, + "y": all_wires[i][0][1] / _IU_PER_MM, + }, + "end": { + "x": all_wires[i][-1][0] / _IU_PER_MM, + "y": all_wires[i][-1][1] / _IU_PER_MM, + }, + } + for i in visited + ] + + if not hasattr(schematic, "symbol"): + return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point} + + pins = _find_pins_on_net(net_points, schematic_path, schematic) + return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point} + + +def count_pins_on_net( + schematic: Any, + schematic_path: str, + net_name: str, + all_wires: List[List[Tuple[int, int]]], + iu_to_wires: Dict[Tuple[int, int], Set[int]], + adjacency: List[Set[int]], + point_to_label: Dict[Tuple[int, int], str], + label_to_points: Dict[str, List[Tuple[int, int]]], +) -> int: + """Count the number of component pins connected to the named net. + + A pin is counted if its IU coordinate falls on the wire-network reachable + from any label anchor for *net_name*, or directly on a label anchor of that + net (pin directly touching a label with no intervening wire). + + Returns the count of distinct (component, pin_num) pairs on this net. + """ + label_positions = label_to_points.get(net_name, []) + if not label_positions: + return 0 + + # Collect the union of all net-points across all label positions for this net + all_net_points: Set[Tuple[int, int]] = set() + for lx, ly in label_positions: + # Include the label anchor itself so pins directly at the label count + all_net_points.add((lx, ly)) + # Trace from this label position into the wire graph + x_mm = lx / _IU_PER_MM + y_mm = ly / _IU_PER_MM + visited, net_points = _find_connected_wires( + x_mm, + y_mm, + all_wires, + iu_to_wires, + adjacency, + point_to_label=point_to_label, + label_to_points=label_to_points, + ) + if net_points: + all_net_points |= net_points + + if not hasattr(schematic, "symbol"): + return 0 + + locator = PinLocator() + seen: Set[Tuple[str, str]] = set() + ref = None + for symbol in schematic.symbol: + try: + if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref) + if not all_pins: + continue + for pin_num, pin_data in all_pins.items(): + pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1])) + if pin_iu in all_net_points: + key = (ref, pin_num) + if key not in seen: + seen.add(key) + except Exception as e: + logger.warning( + f"Error checking pins for {ref if ref is not None else ''}: {e}" + ) + + return len(seen) + + +def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]: + """Return net labels that are not connected to any component pin. + + A label is "floating" when no component pin's IU coordinate falls on the + wire-network reachable from the label's anchor position. These labels are + likely placed off-grid or incorrectly positioned and will cause ERC errors. + + Returns a list of dicts with keys: + - "name": str — the net label text + - "x": float — label X position in mm + - "y": float — label Y position in mm + - "type": str — "label" or "global_label" + """ + all_wires = _parse_wires(schematic) + if all_wires: + adjacency, iu_to_wires = _build_adjacency(all_wires) + else: + adjacency = [] + iu_to_wires = {} + + point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) + + # Build a set of all pin IU positions for fast lookup + pin_iu_set: Set[Tuple[int, int]] = set() + if hasattr(schematic, "symbol"): + locator = PinLocator() + for symbol in schematic.symbol: + try: + if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"): + continue + ref = symbol.property.Reference.value + if ref.startswith("_TEMPLATE"): + continue + all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref) + if not all_pins: + continue + for pin_data in all_pins.values(): + pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1]))) + except Exception as e: + logger.warning(f"Error reading pins for floating-label check: {e}") + + floating: List[Dict[str, Any]] = [] + + if not hasattr(schematic, "label"): + return floating + + for label in schematic.label: + try: + if not hasattr(label, "value"): + continue + name = label.value + if not hasattr(label, "at") or not hasattr(label.at, "value"): + continue + coords = label.at.value + lx_mm = float(coords[0]) + ly_mm = float(coords[1]) + label_iu = _to_iu(lx_mm, ly_mm) + + # Check if the label anchor itself is a pin position + if label_iu in pin_iu_set: + continue + + # Trace the wire-network from this label and check for pins + if all_wires: + _, net_points = _find_connected_wires( + lx_mm, + ly_mm, + all_wires, + iu_to_wires, + adjacency, + point_to_label=point_to_label, + label_to_points=label_to_points, + ) + else: + net_points = None + + if net_points is not None and net_points & pin_iu_set: + continue # at least one pin on this net + + floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"}) + + except Exception as e: + logger.warning(f"Error checking label for floating status: {e}") + + return floating + + +def get_net_at_point( + schematic: Any, schematic_path: str, x_mm: float, y_mm: float +) -> Dict[str, Any]: + """Return the net name at the given coordinate, or null if none found. + + Checks net label positions first (exact IU match within tolerance), then + wire endpoints. Returns a dict with keys: + - "net_name": str or None + - "position": {"x": float, "y": float} + - "source": "net_label" | "wire_endpoint" | None + """ + query_iu = _to_iu(x_mm, y_mm) + position = {"x": x_mm, "y": y_mm} + + # Build label map from schematic + point_to_label, _ = _parse_virtual_connections(schematic, schematic_path) + + # Check if query point is exactly on a net label / power symbol position + label_name = point_to_label.get(query_iu) + if label_name is not None: + return {"net_name": label_name, "position": position, "source": "net_label"} + + # Check if query point is on a wire endpoint + all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else [] + if all_wires: + adjacency, iu_to_wires = _build_adjacency(all_wires) + if query_iu in iu_to_wires: + # Found a wire endpoint — trace the net to get the name + visited, net_points = _find_connected_wires( + x_mm, + y_mm, + all_wires, + iu_to_wires, + adjacency, + point_to_label=point_to_label, + label_to_points=None, + ) + if visited is not None: + net: Optional[str] = None + if net_points: + for pt in net_points: + net = point_to_label.get(pt) + if net is not None: + break + return {"net_name": net, "position": position, "source": "wire_endpoint"} + + return {"net_name": None, "position": position, "source": None} diff --git a/python/commands/wire_dragger.py b/python/commands/wire_dragger.py index 379df22..fb31e99 100644 --- a/python/commands/wire_dragger.py +++ b/python/commands/wire_dragger.py @@ -1,439 +1,439 @@ -""" -WireDragger — drag connected wires when a schematic component is moved. - -All methods operate on in-memory sexpdata lists (no disk I/O). -""" - -import logging -import math -import uuid -from typing import Any, Dict, List, Optional, Tuple - -import sexpdata -from sexpdata import Symbol - -logger = logging.getLogger("kicad_interface") - -# Module-level Symbol constants -_K = { - name: Symbol(name) - for name in [ - "symbol", - "at", - "lib_id", - "mirror", - "lib_symbols", - "pts", - "xy", - "wire", - "junction", - "property", - "stroke", - "width", - "type", - "uuid", - ] -} - -EPS = 1e-4 # mm — coordinate match tolerance - - -def _rotate(x: float, y: float, angle_deg: float) -> Tuple[float, float]: - """Rotate (x, y) around the origin by angle_deg degrees (CCW).""" - if angle_deg == 0: - return x, y - rad = math.radians(angle_deg) - c, s = math.cos(rad), math.sin(rad) - return x * c - y * s, x * s + y * c - - -def _coords_match(ax: float, ay: float, bx: float, by: float, eps: float = EPS) -> bool: - return abs(ax - bx) < eps and abs(ay - by) < eps - - -class WireDragger: - """Pure-logic helpers for wire-endpoint dragging during component moves.""" - - @staticmethod - def find_symbol(sch_data: list, reference: str) -> Any: - """ - Find a placed symbol by reference designator. - - Returns (symbol_item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y) - or None if the reference is not found. - - mirror_x=True means the symbol has (mirror x) — flips the X local axis. - mirror_y=True means the symbol has (mirror y) — flips the Y local axis. - """ - sym_k = _K["symbol"] - prop_k = _K["property"] - at_k = _K["at"] - lib_id_k = _K["lib_id"] - mirror_k = _K["mirror"] - - for item in sch_data: - if not (isinstance(item, list) and item and item[0] == sym_k): - continue - - # Check Reference property - ref_val = None - for sub in item[1:]: - if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k: - if str(sub[1]).strip('"') == "Reference": - ref_val = str(sub[2]).strip('"') - break - if ref_val != reference: - continue - - old_x = old_y = rotation = 0.0 - lib_id = "" - mirror_x = mirror_y = False - - for sub in item[1:]: - if not isinstance(sub, list) or not sub: - continue - tag = sub[0] - if tag == at_k: - if len(sub) >= 3: - old_x = float(sub[1]) - old_y = float(sub[2]) - if len(sub) >= 4: - rotation = float(sub[3]) - elif tag == lib_id_k and len(sub) >= 2: - lib_id = str(sub[1]).strip('"') - elif tag == mirror_k and len(sub) >= 2: - mv = str(sub[1]) - if mv == "x": - mirror_x = True - elif mv == "y": - mirror_y = True - - return item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y - - return None - - @staticmethod - def get_pin_defs(sch_data: list, lib_id: str) -> Dict: - """ - Get pin definitions from lib_symbols for the given lib_id. - - Returns the same dict format as PinLocator.parse_symbol_definition: - {pin_num: {"x": ..., "y": ..., ...}}. - """ - from commands.pin_locator import PinLocator - - lib_sym_k = _K["lib_symbols"] - symbol_k = _K["symbol"] - - for item in sch_data: - if not (isinstance(item, list) and item and item[0] == lib_sym_k): - continue - for sym_def in item[1:]: - if not (isinstance(sym_def, list) and sym_def and sym_def[0] == symbol_k): - continue - if len(sym_def) < 2: - continue - name = str(sym_def[1]).strip('"') - if name == lib_id: - return PinLocator.parse_symbol_definition(sym_def) - break # only one lib_symbols section - return {} - - @staticmethod - def pin_world_xy( - px: float, - py: float, - sym_x: float, - sym_y: float, - rotation: float, - mirror_x: bool, - mirror_y: bool, - ) -> Tuple[float, float]: - """ - Compute the world coordinate of a pin given the symbol transform. - - KiCAD applies mirror first (in local space), then rotation, then translation. - mirror_x negates the local X axis; mirror_y negates the local Y axis. - """ - lx, ly = px, py - if mirror_x: - lx = -lx - if mirror_y: - ly = -ly - rx, ry = _rotate(lx, ly, rotation) - return sym_x + rx, sym_y + ry - - @staticmethod - def compute_pin_positions( - sch_data: list, - reference: str, - new_x: float, - new_y: float, - ) -> Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]]: - """ - Compute world pin positions before and after a component move. - - Returns {pin_num: (old_world_xy, new_world_xy)}. - old_world_xy uses the symbol's current position; new_world_xy uses (new_x, new_y). - """ - found = WireDragger.find_symbol(sch_data, reference) - if found is None: - return {} - _, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = found - - pins = WireDragger.get_pin_defs(sch_data, lib_id) - result: Dict[str, Tuple] = {} - for pin_num, pin in pins.items(): - px, py = pin["x"], pin["y"] - old_wx, old_wy = WireDragger.pin_world_xy( - px, py, old_x, old_y, rotation, mirror_x, mirror_y - ) - new_wx, new_wy = WireDragger.pin_world_xy( - px, py, new_x, new_y, rotation, mirror_x, mirror_y - ) - result[pin_num] = ( - (round(old_wx, 6), round(old_wy, 6)), - (round(new_wx, 6), round(new_wy, 6)), - ) - return result - - @staticmethod - def drag_wires( - sch_data: list, - old_to_new: Dict[Tuple[float, float], Tuple[float, float]], - eps: float = EPS, - ) -> Dict: - """ - Move wire endpoints and junctions from old positions to new positions. - Removes zero-length wires that result from the move. - Modifies sch_data in place. - - old_to_new: {(old_x, old_y): (new_x, new_y)} - - Returns {'endpoints_moved': N, 'wires_removed': M}. - """ - wire_k = _K["wire"] - pts_k = _K["pts"] - xy_k = _K["xy"] - junction_k = _K["junction"] - at_k = _K["at"] - - def find_new(x: float, y: float) -> Optional[Tuple[float, float]]: - for (ox, oy), (nx, ny) in old_to_new.items(): - if _coords_match(x, y, ox, oy, eps): - return nx, ny - return None - - endpoints_moved = 0 - zero_length_indices = [] - - # First pass: update wire endpoints - for idx, item in enumerate(sch_data): - if not (isinstance(item, list) and item and item[0] == wire_k): - continue - - pts_sub = None - for sub in item[1:]: - if isinstance(sub, list) and sub and sub[0] == pts_k: - pts_sub = sub - break - if pts_sub is None: - continue - - xy_items = [ - p for p in pts_sub[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == xy_k - ] - for xy_item in xy_items: - nc = find_new(float(xy_item[1]), float(xy_item[2])) - if nc is not None: - xy_item[1] = nc[0] - xy_item[2] = nc[1] - endpoints_moved += 1 - - # Check if this wire is now zero-length - if len(xy_items) >= 2: - x1, y1 = float(xy_items[0][1]), float(xy_items[0][2]) - x2, y2 = float(xy_items[-1][1]), float(xy_items[-1][2]) - if _coords_match(x1, y1, x2, y2, eps): - zero_length_indices.append(idx) - - # Remove zero-length wires (backwards to preserve indices) - for idx in reversed(zero_length_indices): - del sch_data[idx] - - # Second pass: update junctions - for item in sch_data: - if not (isinstance(item, list) and item and item[0] == junction_k): - continue - for sub in item[1:]: - if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3: - nc = find_new(float(sub[1]), float(sub[2])) - if nc is not None: - sub[1] = nc[0] - sub[2] = nc[1] - break - - return { - "endpoints_moved": endpoints_moved, - "wires_removed": len(zero_length_indices), - } - - @staticmethod - def update_symbol_position(sch_data: list, reference: str, new_x: float, new_y: float) -> bool: - """ - Update the (at x y rot) of the named symbol in sch_data. - Returns True if the symbol was found and updated. - """ - found = WireDragger.find_symbol(sch_data, reference) - if found is None: - return False - item = found[0] - at_k = _K["at"] - prop_k = _K["property"] - - # Find current position and compute delta - old_x = old_y = None - for sub in item[1:]: - if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3: - old_x, old_y = sub[1], sub[2] - sub[1] = new_x - sub[2] = new_y - break - if old_x is None or old_y is None: - return False - - dx = new_x - old_x - dy = new_y - old_y - - # Shift all property label positions by the same delta - for sub in item[1:]: - if isinstance(sub, list) and sub and sub[0] == prop_k: - for psub in sub[1:]: - if isinstance(psub, list) and psub and psub[0] == at_k and len(psub) >= 3: - psub[1] += dx - psub[2] += dy - break - return True - - @staticmethod - def _make_wire_sexp(x1: float, y1: float, x2: float, y2: float) -> list: - """Build a wire s-expression list in KiCAD schematic format.""" - wire_uuid = str(uuid.uuid4()) - return [ - _K["wire"], - [_K["pts"], [_K["xy"], x1, y1], [_K["xy"], x2, y2]], - [_K["stroke"], [_K["width"], 0], [_K["type"], Symbol("default")]], - [_K["uuid"], wire_uuid], - ] - - @staticmethod - def get_all_stationary_pin_positions( - sch_data: list, - moved_reference: str, - ) -> Dict[Tuple[float, float], str]: - """ - Return a map of {world_xy: reference} for every pin of every symbol - in sch_data *except* moved_reference. - - This is used to detect pins of stationary components that coincide - with pins of the moved component (touching-pin connections). - """ - sym_k = _K["symbol"] - prop_k = _K["property"] - result: Dict[Tuple[float, float], str] = {} - - for item in sch_data: - if not (isinstance(item, list) and item and item[0] == sym_k): - continue - # Determine reference - ref_val = None - for sub in item[1:]: - if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k: - if str(sub[1]).strip('"') == "Reference": - ref_val = str(sub[2]).strip('"') - break - if ref_val is None or ref_val == moved_reference: - continue - # Skip template / power symbols whose references start with special chars - # but we still want to handle them — no filtering needed here. - - # Find lib_id and position for this symbol - found = WireDragger.find_symbol(sch_data, ref_val) - if found is None: - continue - _, sx, sy, rotation, lib_id, mirror_x, mirror_y = found - pins = WireDragger.get_pin_defs(sch_data, lib_id) - for pin_num, pin in pins.items(): - wx, wy = WireDragger.pin_world_xy( - pin["x"], pin["y"], sx, sy, rotation, mirror_x, mirror_y - ) - key = (round(wx, 6), round(wy, 6)) - result[key] = ref_val - - return result - - @staticmethod - def synthesize_touching_pin_wires( - sch_data: list, - moved_reference: str, - pin_positions: Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]], - eps: float = EPS, - ) -> int: - """ - Detect touching-pin connections and synthesize wire segments to bridge gaps - created by moving a component. - - For each pin of *moved_reference* whose old world position coincides with - a pin of a stationary component: - - If the pin moved (old_xy != new_xy), insert a wire from old_xy to new_xy. - - If the pin now lands on another stationary pin's position, skip (they touch again). - - If old_xy == new_xy, do nothing (no gap was created). - - Modifies sch_data in place. - Returns the number of wire segments synthesized. - """ - if not pin_positions: - return 0 - - stationary_pins = WireDragger.get_all_stationary_pin_positions(sch_data, moved_reference) - if not stationary_pins: - return 0 - - synthesized = 0 - - for pin_num, (old_xy, new_xy) in pin_positions.items(): - # Check if a stationary pin touches this pin's old position - touching = any( - _coords_match(old_xy[0], old_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins - ) - if not touching: - continue - - # The pin has moved — check if it actually separated - if _coords_match(old_xy[0], old_xy[1], new_xy[0], new_xy[1], eps): - # Pin didn't actually move; no gap - continue - - # Check if the pin's new position happens to touch another stationary pin - # (component moved into a different touching position — no wire needed) - rejoining = any( - _coords_match(new_xy[0], new_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins - ) - if rejoining: - logger.debug( - f"Pin {moved_reference}/{pin_num} moved from {old_xy} to {new_xy} " - f"and rejoins another stationary pin; no wire synthesized" - ) - continue - - logger.info( - f"Synthesizing wire for touching-pin connection: " - f"{moved_reference}/{pin_num} moved from {old_xy} to {new_xy}" - ) - wire = WireDragger._make_wire_sexp(old_xy[0], old_xy[1], new_xy[0], new_xy[1]) - # Insert before the last item (sheet_instances) to keep file tidy, - # but appending is also valid — just append. - sch_data.append(wire) - synthesized += 1 - - return synthesized +""" +WireDragger — drag connected wires when a schematic component is moved. + +All methods operate on in-memory sexpdata lists (no disk I/O). +""" + +import logging +import math +import uuid +from typing import Any, Dict, List, Optional, Tuple + +import sexpdata +from sexpdata import Symbol + +logger = logging.getLogger("kicad_interface") + +# Module-level Symbol constants +_K = { + name: Symbol(name) + for name in [ + "symbol", + "at", + "lib_id", + "mirror", + "lib_symbols", + "pts", + "xy", + "wire", + "junction", + "property", + "stroke", + "width", + "type", + "uuid", + ] +} + +EPS = 1e-4 # mm — coordinate match tolerance + + +def _rotate(x: float, y: float, angle_deg: float) -> Tuple[float, float]: + """Rotate (x, y) around the origin by angle_deg degrees (CCW).""" + if angle_deg == 0: + return x, y + rad = math.radians(angle_deg) + c, s = math.cos(rad), math.sin(rad) + return x * c - y * s, x * s + y * c + + +def _coords_match(ax: float, ay: float, bx: float, by: float, eps: float = EPS) -> bool: + return abs(ax - bx) < eps and abs(ay - by) < eps + + +class WireDragger: + """Pure-logic helpers for wire-endpoint dragging during component moves.""" + + @staticmethod + def find_symbol(sch_data: list, reference: str) -> Any: + """ + Find a placed symbol by reference designator. + + Returns (symbol_item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y) + or None if the reference is not found. + + mirror_x=True means the symbol has (mirror x) — flips the X local axis. + mirror_y=True means the symbol has (mirror y) — flips the Y local axis. + """ + sym_k = _K["symbol"] + prop_k = _K["property"] + at_k = _K["at"] + lib_id_k = _K["lib_id"] + mirror_k = _K["mirror"] + + for item in sch_data: + if not (isinstance(item, list) and item and item[0] == sym_k): + continue + + # Check Reference property + ref_val = None + for sub in item[1:]: + if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k: + if str(sub[1]).strip('"') == "Reference": + ref_val = str(sub[2]).strip('"') + break + if ref_val != reference: + continue + + old_x = old_y = rotation = 0.0 + lib_id = "" + mirror_x = mirror_y = False + + for sub in item[1:]: + if not isinstance(sub, list) or not sub: + continue + tag = sub[0] + if tag == at_k: + if len(sub) >= 3: + old_x = float(sub[1]) + old_y = float(sub[2]) + if len(sub) >= 4: + rotation = float(sub[3]) + elif tag == lib_id_k and len(sub) >= 2: + lib_id = str(sub[1]).strip('"') + elif tag == mirror_k and len(sub) >= 2: + mv = str(sub[1]) + if mv == "x": + mirror_x = True + elif mv == "y": + mirror_y = True + + return item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y + + return None + + @staticmethod + def get_pin_defs(sch_data: list, lib_id: str) -> Dict: + """ + Get pin definitions from lib_symbols for the given lib_id. + + Returns the same dict format as PinLocator.parse_symbol_definition: + {pin_num: {"x": ..., "y": ..., ...}}. + """ + from commands.pin_locator import PinLocator + + lib_sym_k = _K["lib_symbols"] + symbol_k = _K["symbol"] + + for item in sch_data: + if not (isinstance(item, list) and item and item[0] == lib_sym_k): + continue + for sym_def in item[1:]: + if not (isinstance(sym_def, list) and sym_def and sym_def[0] == symbol_k): + continue + if len(sym_def) < 2: + continue + name = str(sym_def[1]).strip('"') + if name == lib_id: + return PinLocator.parse_symbol_definition(sym_def) + break # only one lib_symbols section + return {} + + @staticmethod + def pin_world_xy( + px: float, + py: float, + sym_x: float, + sym_y: float, + rotation: float, + mirror_x: bool, + mirror_y: bool, + ) -> Tuple[float, float]: + """ + Compute the world coordinate of a pin given the symbol transform. + + KiCAD applies mirror first (in local space), then rotation, then translation. + mirror_x negates the local X axis; mirror_y negates the local Y axis. + """ + lx, ly = px, py + if mirror_x: + lx = -lx + if mirror_y: + ly = -ly + rx, ry = _rotate(lx, ly, rotation) + return sym_x + rx, sym_y + ry + + @staticmethod + def compute_pin_positions( + sch_data: list, + reference: str, + new_x: float, + new_y: float, + ) -> Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]]: + """ + Compute world pin positions before and after a component move. + + Returns {pin_num: (old_world_xy, new_world_xy)}. + old_world_xy uses the symbol's current position; new_world_xy uses (new_x, new_y). + """ + found = WireDragger.find_symbol(sch_data, reference) + if found is None: + return {} + _, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = found + + pins = WireDragger.get_pin_defs(sch_data, lib_id) + result: Dict[str, Tuple] = {} + for pin_num, pin in pins.items(): + px, py = pin["x"], pin["y"] + old_wx, old_wy = WireDragger.pin_world_xy( + px, py, old_x, old_y, rotation, mirror_x, mirror_y + ) + new_wx, new_wy = WireDragger.pin_world_xy( + px, py, new_x, new_y, rotation, mirror_x, mirror_y + ) + result[pin_num] = ( + (round(old_wx, 6), round(old_wy, 6)), + (round(new_wx, 6), round(new_wy, 6)), + ) + return result + + @staticmethod + def drag_wires( + sch_data: list, + old_to_new: Dict[Tuple[float, float], Tuple[float, float]], + eps: float = EPS, + ) -> Dict: + """ + Move wire endpoints and junctions from old positions to new positions. + Removes zero-length wires that result from the move. + Modifies sch_data in place. + + old_to_new: {(old_x, old_y): (new_x, new_y)} + + Returns {'endpoints_moved': N, 'wires_removed': M}. + """ + wire_k = _K["wire"] + pts_k = _K["pts"] + xy_k = _K["xy"] + junction_k = _K["junction"] + at_k = _K["at"] + + def find_new(x: float, y: float) -> Optional[Tuple[float, float]]: + for (ox, oy), (nx, ny) in old_to_new.items(): + if _coords_match(x, y, ox, oy, eps): + return nx, ny + return None + + endpoints_moved = 0 + zero_length_indices = [] + + # First pass: update wire endpoints + for idx, item in enumerate(sch_data): + if not (isinstance(item, list) and item and item[0] == wire_k): + continue + + pts_sub = None + for sub in item[1:]: + if isinstance(sub, list) and sub and sub[0] == pts_k: + pts_sub = sub + break + if pts_sub is None: + continue + + xy_items = [ + p for p in pts_sub[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == xy_k + ] + for xy_item in xy_items: + nc = find_new(float(xy_item[1]), float(xy_item[2])) + if nc is not None: + xy_item[1] = nc[0] + xy_item[2] = nc[1] + endpoints_moved += 1 + + # Check if this wire is now zero-length + if len(xy_items) >= 2: + x1, y1 = float(xy_items[0][1]), float(xy_items[0][2]) + x2, y2 = float(xy_items[-1][1]), float(xy_items[-1][2]) + if _coords_match(x1, y1, x2, y2, eps): + zero_length_indices.append(idx) + + # Remove zero-length wires (backwards to preserve indices) + for idx in reversed(zero_length_indices): + del sch_data[idx] + + # Second pass: update junctions + for item in sch_data: + if not (isinstance(item, list) and item and item[0] == junction_k): + continue + for sub in item[1:]: + if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3: + nc = find_new(float(sub[1]), float(sub[2])) + if nc is not None: + sub[1] = nc[0] + sub[2] = nc[1] + break + + return { + "endpoints_moved": endpoints_moved, + "wires_removed": len(zero_length_indices), + } + + @staticmethod + def update_symbol_position(sch_data: list, reference: str, new_x: float, new_y: float) -> bool: + """ + Update the (at x y rot) of the named symbol in sch_data. + Returns True if the symbol was found and updated. + """ + found = WireDragger.find_symbol(sch_data, reference) + if found is None: + return False + item = found[0] + at_k = _K["at"] + prop_k = _K["property"] + + # Find current position and compute delta + old_x = old_y = None + for sub in item[1:]: + if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3: + old_x, old_y = sub[1], sub[2] + sub[1] = new_x + sub[2] = new_y + break + if old_x is None or old_y is None: + return False + + dx = new_x - old_x + dy = new_y - old_y + + # Shift all property label positions by the same delta + for sub in item[1:]: + if isinstance(sub, list) and sub and sub[0] == prop_k: + for psub in sub[1:]: + if isinstance(psub, list) and psub and psub[0] == at_k and len(psub) >= 3: + psub[1] += dx + psub[2] += dy + break + return True + + @staticmethod + def _make_wire_sexp(x1: float, y1: float, x2: float, y2: float) -> list: + """Build a wire s-expression list in KiCAD schematic format.""" + wire_uuid = str(uuid.uuid4()) + return [ + _K["wire"], + [_K["pts"], [_K["xy"], x1, y1], [_K["xy"], x2, y2]], + [_K["stroke"], [_K["width"], 0], [_K["type"], Symbol("default")]], + [_K["uuid"], wire_uuid], + ] + + @staticmethod + def get_all_stationary_pin_positions( + sch_data: list, + moved_reference: str, + ) -> Dict[Tuple[float, float], str]: + """ + Return a map of {world_xy: reference} for every pin of every symbol + in sch_data *except* moved_reference. + + This is used to detect pins of stationary components that coincide + with pins of the moved component (touching-pin connections). + """ + sym_k = _K["symbol"] + prop_k = _K["property"] + result: Dict[Tuple[float, float], str] = {} + + for item in sch_data: + if not (isinstance(item, list) and item and item[0] == sym_k): + continue + # Determine reference + ref_val = None + for sub in item[1:]: + if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k: + if str(sub[1]).strip('"') == "Reference": + ref_val = str(sub[2]).strip('"') + break + if ref_val is None or ref_val == moved_reference: + continue + # Skip template / power symbols whose references start with special chars + # but we still want to handle them — no filtering needed here. + + # Find lib_id and position for this symbol + found = WireDragger.find_symbol(sch_data, ref_val) + if found is None: + continue + _, sx, sy, rotation, lib_id, mirror_x, mirror_y = found + pins = WireDragger.get_pin_defs(sch_data, lib_id) + for pin_num, pin in pins.items(): + wx, wy = WireDragger.pin_world_xy( + pin["x"], pin["y"], sx, sy, rotation, mirror_x, mirror_y + ) + key = (round(wx, 6), round(wy, 6)) + result[key] = ref_val + + return result + + @staticmethod + def synthesize_touching_pin_wires( + sch_data: list, + moved_reference: str, + pin_positions: Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]], + eps: float = EPS, + ) -> int: + """ + Detect touching-pin connections and synthesize wire segments to bridge gaps + created by moving a component. + + For each pin of *moved_reference* whose old world position coincides with + a pin of a stationary component: + - If the pin moved (old_xy != new_xy), insert a wire from old_xy to new_xy. + - If the pin now lands on another stationary pin's position, skip (they touch again). + - If old_xy == new_xy, do nothing (no gap was created). + + Modifies sch_data in place. + Returns the number of wire segments synthesized. + """ + if not pin_positions: + return 0 + + stationary_pins = WireDragger.get_all_stationary_pin_positions(sch_data, moved_reference) + if not stationary_pins: + return 0 + + synthesized = 0 + + for pin_num, (old_xy, new_xy) in pin_positions.items(): + # Check if a stationary pin touches this pin's old position + touching = any( + _coords_match(old_xy[0], old_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins + ) + if not touching: + continue + + # The pin has moved — check if it actually separated + if _coords_match(old_xy[0], old_xy[1], new_xy[0], new_xy[1], eps): + # Pin didn't actually move; no gap + continue + + # Check if the pin's new position happens to touch another stationary pin + # (component moved into a different touching position — no wire needed) + rejoining = any( + _coords_match(new_xy[0], new_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins + ) + if rejoining: + logger.debug( + f"Pin {moved_reference}/{pin_num} moved from {old_xy} to {new_xy} " + f"and rejoins another stationary pin; no wire synthesized" + ) + continue + + logger.info( + f"Synthesizing wire for touching-pin connection: " + f"{moved_reference}/{pin_num} moved from {old_xy} to {new_xy}" + ) + wire = WireDragger._make_wire_sexp(old_xy[0], old_xy[1], new_xy[0], new_xy[1]) + # Insert before the last item (sheet_instances) to keep file tidy, + # but appending is also valid — just append. + sch_data.append(wire) + synthesized += 1 + + return synthesized diff --git a/python/commands/wire_manager.py b/python/commands/wire_manager.py index 982d50e..8d56540 100644 --- a/python/commands/wire_manager.py +++ b/python/commands/wire_manager.py @@ -1,891 +1,891 @@ -""" -Wire Manager for KiCad Schematics - -Handles wire creation using S-expression manipulation, similar to dynamic symbol loading. -kicad-skip's wire API doesn't support creating wires with standard parameters, so we -manipulate the .kicad_sch file directly. -""" - -import logging -import math -import re -import tempfile -import uuid -from pathlib import Path -from typing import Any, List, Optional, Tuple - -import sexpdata -from sexpdata import Symbol - -logger = logging.getLogger("kicad_interface") - -# Module-level Symbol constants — avoids repeated allocation on every call -_SYM_WIRE = Symbol("wire") -_SYM_PTS = Symbol("pts") -_SYM_XY = Symbol("xy") -_SYM_AT = Symbol("at") -_SYM_LABEL = Symbol("label") -_SYM_STROKE = Symbol("stroke") -_SYM_WIDTH = Symbol("width") -_SYM_TYPE = Symbol("type") -_SYM_UUID = Symbol("uuid") -_SYM_SHEET_INSTANCES = Symbol("sheet_instances") - - -def _find_insertion_point(content: str) -> int: - """Find the right place to insert new elements in a .kicad_sch file. - - Looks for (sheet_instances (KiCad 8) first, falls back to inserting - before the final closing paren (KiCad 9+). - """ - marker = "(sheet_instances" - pos = content.rfind(marker) - if pos != -1: - return pos - pos = content.rfind(")") - if pos == -1: - raise ValueError("Could not find insertion point in schematic") - return pos - - -def _text_insert(file_path: Path, sexp_text: str) -> bool: - """Insert S-expression text into a .kicad_sch file preserving formatting.""" - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - - insert_at = _find_insertion_point(content) - content = content[:insert_at] + sexp_text + content[insert_at:] - - with open(file_path, "w", encoding="utf-8") as f: - f.write(content) - return True - - -def _make_hierarchical_label_text( - text: str, - position: List[float], - shape: str = "bidirectional", - orientation: int = 0, -) -> str: - """Generate a hierarchical_label S-expression as formatted text. - - orientation: 0=right (label points right, justify left), - 180=left (label points left, justify right), - 90/270=vertical. - """ - uid = str(uuid.uuid4()) - justify = "right" if orientation == 180 else "left" - return ( - f'\t(hierarchical_label "{text}"\n' - f"\t\t(shape {shape})\n" - f"\t\t(at {position[0]} {position[1]} {orientation})\n" - f"\t\t(effects\n" - f"\t\t\t(font\n" - f"\t\t\t\t(size 1.27 1.27)\n" - f"\t\t\t)\n" - f"\t\t\t(justify {justify})\n" - f"\t\t)\n" - f'\t\t(uuid "{uid}")\n' - f"\t)\n" - ) - - -def _make_sheet_pin_text( - pin_name: str, - pin_type: str, - position: List[float], - orientation: int = 0, -) -> str: - """Generate a sheet pin S-expression as formatted text (indented for inside sheet block). - - orientation: 0=right side of sheet box, 180=left side. - """ - uid = str(uuid.uuid4()) - justify = "left" if orientation == 0 else "right" - return ( - f'\t\t(pin "{pin_name}" {pin_type}\n' - f"\t\t\t(at {position[0]} {position[1]} {orientation})\n" - f'\t\t\t(uuid "{uid}")\n' - f"\t\t\t(effects\n" - f"\t\t\t\t(font\n" - f"\t\t\t\t\t(size 1.27 1.27)\n" - f"\t\t\t\t)\n" - f"\t\t\t\t(justify {justify})\n" - f"\t\t\t)\n" - f"\t\t)\n" - ) - - -class WireManager: - """Manage wires in KiCad schematics using S-expression manipulation""" - - @staticmethod - def add_wire( - schematic_path: Path, - start_point: List[float], - end_point: List[float], - stroke_width: float = 0, - stroke_type: str = "default", - ) -> bool: - """ - Add a wire to the schematic using S-expression manipulation - - Args: - schematic_path: Path to .kicad_sch file - start_point: [x, y] coordinates for wire start - end_point: [x, y] coordinates for wire end - stroke_width: Wire width (default 0 for standard) - stroke_type: Stroke type (default, solid, dashed, etc.) - - Returns: - True if successful, False otherwise - """ - try: - # Read schematic - with open(schematic_path, "r", encoding="utf-8") as f: - sch_content = f.read() - - sch_data = sexpdata.loads(sch_content) - - # Break any existing wire that passes through a new endpoint (T-junction support) - for pt in (start_point, end_point): - splits = WireManager._break_wires_at_point(sch_data, pt) - if splits: - logger.info(f"Broke {splits} wire(s) at new wire endpoint {pt}") - - # Create wire S-expression - # Format: (wire (pts (xy x1 y1) (xy x2 y2)) (stroke (width N) (type default)) (uuid ...)) - wire_sexp = WireManager._make_wire_sexp( - start_point, end_point, stroke_width, stroke_type - ) - - # Find insertion point (before sheet_instances) - sheet_instances_index = None - for i, item in enumerate(sch_data): - if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: - sheet_instances_index = i - break - - if sheet_instances_index is None: - logger.error("No sheet_instances section found in schematic") - return False - - # Insert wire before sheet_instances - sch_data.insert(sheet_instances_index, wire_sexp) - logger.info(f"Injected wire from {start_point} to {end_point}") - - # Write back - with open(schematic_path, "w", encoding="utf-8") as f: - output = sexpdata.dumps(sch_data) - f.write(output) - - logger.info(f"Successfully added wire to {schematic_path.name}") - return True - - except Exception as e: - logger.error(f"Error adding wire: {e}") - import traceback - - logger.error(traceback.format_exc()) - return False - - @staticmethod - def add_polyline_wire( - schematic_path: Path, - points: List[List[float]], - stroke_width: float = 0, - stroke_type: str = "default", - ) -> bool: - """ - Add a multi-segment wire (polyline) to the schematic - - Args: - schematic_path: Path to .kicad_sch file - points: List of [x, y] coordinates for each point in the path - stroke_width: Wire width - stroke_type: Stroke type - - Returns: - True if successful, False otherwise - """ - try: - if len(points) < 2: - logger.error("Polyline requires at least 2 points") - return False - - # Read schematic - with open(schematic_path, "r", encoding="utf-8") as f: - sch_content = f.read() - - sch_data = sexpdata.loads(sch_content) - - # Break any existing wire at the outer endpoints of the new path - for pt in (points[0], points[-1]): - splits = WireManager._break_wires_at_point(sch_data, pt) - if splits: - logger.info(f"Broke {splits} wire(s) at new polyline endpoint {pt}") - - # KiCAD wire elements only support exactly 2 pts each. - # Split N waypoints into N-1 individual wire segments. - wire_sexps = [ - WireManager._make_wire_sexp(points[i], points[i + 1], stroke_width, stroke_type) - for i in range(len(points) - 1) - ] - - # Find insertion point - sheet_instances_index = None - for i, item in enumerate(sch_data): - if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: - sheet_instances_index = i - break - - if sheet_instances_index is None: - logger.error("No sheet_instances section found in schematic") - return False - - # Insert all segments (in reverse so order is preserved after inserts) - for wire_sexp in reversed(wire_sexps): - sch_data.insert(sheet_instances_index, wire_sexp) - logger.info( - f"Injected {len(wire_sexps)} wire segments for {len(points)}-point polyline" - ) - - # Write back - with open(schematic_path, "w", encoding="utf-8") as f: - output = sexpdata.dumps(sch_data) - f.write(output) - - logger.info(f"Successfully added polyline wire to {schematic_path.name}") - return True - - except Exception as e: - logger.error(f"Error adding polyline wire: {e}") - import traceback - - logger.error(traceback.format_exc()) - return False - - @staticmethod - def add_label( - schematic_path: Path, - text: str, - position: List[float], - label_type: str = "label", - orientation: int = 0, - ) -> bool: - """ - Add a net label to the schematic - - Args: - schematic_path: Path to .kicad_sch file - text: Label text (net name) - position: [x, y] coordinates for label - label_type: Type of label ('label', 'global_label', 'hierarchical_label') - orientation: Rotation angle (0, 90, 180, 270) - - Returns: - True if successful, False otherwise - """ - try: - # Read schematic - with open(schematic_path, "r", encoding="utf-8") as f: - sch_content = f.read() - - sch_data = sexpdata.loads(sch_content) - - # Create label S-expression - # Format: (label "TEXT" (at x y angle) (effects (font (size 1.27 1.27)))) - label_sexp = [ - Symbol(label_type), - text, - [Symbol("at"), position[0], position[1], orientation], - [Symbol("fields_autoplaced"), Symbol("yes")], - [ - Symbol("effects"), - [Symbol("font"), [Symbol("size"), 1.27, 1.27]], - [Symbol("justify"), Symbol("left"), Symbol("bottom")], - ], - [Symbol("uuid"), str(uuid.uuid4())], - ] - - # Find insertion point - sheet_instances_index = None - for i, item in enumerate(sch_data): - if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: - sheet_instances_index = i - break - - if sheet_instances_index is None: - logger.error("No sheet_instances section found in schematic") - return False - - # Insert label - sch_data.insert(sheet_instances_index, label_sexp) - logger.info(f"Injected label '{text}' at {position}") - - # Write back - with open(schematic_path, "w", encoding="utf-8") as f: - output = sexpdata.dumps(sch_data) - f.write(output) - - logger.info(f"Successfully added label to {schematic_path.name}") - return True - - except Exception as e: - logger.error(f"Error adding label: {e}") - import traceback - - logger.error(traceback.format_exc()) - return False - - @staticmethod - def _parse_wire( - wire_item: Any, - ) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]: - """ - Parse a wire S-expression item in a single pass. - Returns ((x1,y1), (x2,y2), stroke_width, stroke_type), or None if not a valid wire. - """ - if not (isinstance(wire_item, list) and len(wire_item) >= 2 and wire_item[0] == _SYM_WIRE): - return None - start = end = None - stroke_width: float = 0 - stroke_type: str = "default" - for part in wire_item[1:]: - if not isinstance(part, list) or not part: - continue - tag = part[0] - if tag == _SYM_PTS: - found: List[Tuple[float, float]] = [] - for p in part[1:]: - if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_XY: - found.append((float(p[1]), float(p[2]))) - if len(found) == 2: - break - if len(found) == 2: - start, end = found[0], found[1] - elif tag == _SYM_STROKE: - for sp in part[1:]: - if isinstance(sp, list) and len(sp) >= 2: - if sp[0] == _SYM_WIDTH: - stroke_width = sp[1] - elif sp[0] == _SYM_TYPE: - stroke_type = str(sp[1]) - if start is not None and end is not None: - return start, end, stroke_width, stroke_type - return None - - @staticmethod - def _point_strictly_on_wire( - px: float, - py: float, - x1: float, - y1: float, - x2: float, - y2: float, - eps: float = 1e-6, - ) -> bool: - """ - Return True if (px, py) lies strictly between (x1,y1) and (x2,y2) - on a horizontal or vertical wire segment (not at either endpoint). - """ - if abs(y1 - y2) < eps: # horizontal wire - if abs(py - y1) > eps: - return False - lo, hi = min(x1, x2), max(x1, x2) - return lo + eps < px < hi - eps - if abs(x1 - x2) < eps: # vertical wire - if abs(px - x1) > eps: - return False - lo, hi = min(y1, y2), max(y1, y2) - return lo + eps < py < hi - eps - return False - - @staticmethod - def _make_wire_sexp( - start: List[float], - end: List[float], - stroke_width: float = 0, - stroke_type: str = "default", - ) -> list: - return [ - _SYM_WIRE, - [_SYM_PTS, [_SYM_XY, start[0], start[1]], [_SYM_XY, end[0], end[1]]], - [_SYM_STROKE, [_SYM_WIDTH, stroke_width], [_SYM_TYPE, Symbol(stroke_type)]], - [_SYM_UUID, str(uuid.uuid4())], - ] - - @staticmethod - def _break_wires_at_point(sch_data: list, position: List[float]) -> int: - """ - Split any wire segment that passes through *position* as a strict - midpoint (i.e. position is not an existing endpoint). Mirrors - KiCAD's SCH_LINE_WIRE_BUS_TOOL::BreakSegments behaviour. - - Returns the number of wires split. - """ - px, py = float(position[0]), float(position[1]) - splits = 0 - i = 0 - while i < len(sch_data): - parsed = WireManager._parse_wire(sch_data[i]) - if parsed is not None: - (x1, y1), (x2, y2), stroke_width, stroke_type = parsed - if WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2): - seg_a = WireManager._make_wire_sexp( - [x1, y1], [px, py], stroke_width, stroke_type - ) - seg_b = WireManager._make_wire_sexp( - [px, py], [x2, y2], stroke_width, stroke_type - ) - sch_data[i : i + 1] = [seg_a, seg_b] - logger.info(f"Split wire ({x1},{y1})->({x2},{y2}) at ({px},{py})") - splits += 1 - i += 2 # skip the two new segments - continue - i += 1 - return splits - - @staticmethod - def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool: - """ - Add a junction (connection dot) to the schematic. - - Mirrors KiCAD's AddJunction behaviour: any wire whose interior passes - through *position* is split into two segments at that point so that - the BFS-based get_wire_connections tool can traverse the T/X branch. - - Args: - schematic_path: Path to .kicad_sch file - position: [x, y] coordinates for junction - diameter: Junction diameter (0 for default) - - Returns: - True if successful, False otherwise - """ - try: - # Read schematic - with open(schematic_path, "r", encoding="utf-8") as f: - sch_content = f.read() - - sch_data = sexpdata.loads(sch_content) - - # Split any wire that passes through the junction as a midpoint - # (mirrors KiCAD's AddJunction / BreakSegments behaviour) - splits = WireManager._break_wires_at_point(sch_data, position) - if splits: - logger.info(f"Broke {splits} wire(s) at junction position {position}") - - # Create junction S-expression - # Format: (junction (at x y) (diameter 0) (color 0 0 0 0) (uuid ...)) - junction_sexp = [ - Symbol("junction"), - [Symbol("at"), position[0], position[1]], - [Symbol("diameter"), diameter], - [Symbol("color"), 0, 0, 0, 0], - [Symbol("uuid"), str(uuid.uuid4())], - ] - - # Find insertion point - sheet_instances_index = None - for i, item in enumerate(sch_data): - if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: - sheet_instances_index = i - break - - if sheet_instances_index is None: - logger.error("No sheet_instances section found in schematic") - return False - - # Insert junction - sch_data.insert(sheet_instances_index, junction_sexp) - logger.info(f"Injected junction at {position}") - - # Write back - with open(schematic_path, "w", encoding="utf-8") as f: - output = sexpdata.dumps(sch_data) - f.write(output) - - logger.info(f"Successfully added junction to {schematic_path.name}") - return True - - except Exception as e: - logger.error(f"Error adding junction: {e}") - import traceback - - logger.error(traceback.format_exc()) - return False - - @staticmethod - def add_no_connect(schematic_path: Path, position: List[float]) -> bool: - """ - Add a no-connect flag to the schematic - - Args: - schematic_path: Path to .kicad_sch file - position: [x, y] coordinates for no-connect flag - - Returns: - True if successful, False otherwise - """ - try: - # Read schematic - with open(schematic_path, "r", encoding="utf-8") as f: - sch_content = f.read() - - sch_data = sexpdata.loads(sch_content) - - # Create no_connect S-expression - # Format: (no_connect (at x y) (uuid ...)) - no_connect_sexp = [ - Symbol("no_connect"), - [Symbol("at"), position[0], position[1]], - [Symbol("uuid"), str(uuid.uuid4())], - ] - - # Find insertion point - sheet_instances_index = None - for i, item in enumerate(sch_data): - if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: - sheet_instances_index = i - break - - if sheet_instances_index is None: - logger.error("No sheet_instances section found in schematic") - return False - - # Insert no_connect - sch_data.insert(sheet_instances_index, no_connect_sexp) - logger.info(f"Injected no-connect at {position}") - - # Write back - with open(schematic_path, "w", encoding="utf-8") as f: - output = sexpdata.dumps(sch_data) - f.write(output) - - logger.info(f"Successfully added no-connect to {schematic_path.name}") - return True - - except Exception as e: - logger.error(f"Error adding no-connect: {e}") - import traceback - - logger.error(traceback.format_exc()) - return False - - @staticmethod - def delete_wire( - schematic_path: Path, - start_point: List[float], - end_point: List[float], - tolerance: float = 0.5, - ) -> bool: - """ - Delete a wire from the schematic matching given start/end coordinates. - - Args: - schematic_path: Path to .kicad_sch file - start_point: [x, y] coordinates for wire start - end_point: [x, y] coordinates for wire end - tolerance: Maximum coordinate difference to consider a match (mm) - - Returns: - True if a wire was found and removed, False otherwise - """ - try: - with open(schematic_path, "r", encoding="utf-8") as f: - sch_content = f.read() - - sch_data = sexpdata.loads(sch_content) - - sx, sy = start_point - ex, ey = end_point - - for i, item in enumerate(sch_data): - if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE): - continue - - # Extract pts from the wire s-expression - pts_list = None - for part in item[1:]: - if isinstance(part, list) and len(part) > 0 and part[0] == _SYM_PTS: - pts_list = part - break - - if pts_list is None: - continue - - xy_points = [ - p - for p in pts_list[1:] - if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_XY - ] - if len(xy_points) < 2: - continue - - x1, y1 = float(xy_points[0][1]), float(xy_points[0][2]) - x2, y2 = float(xy_points[-1][1]), float(xy_points[-1][2]) - - match_fwd = ( - abs(x1 - sx) < tolerance - and abs(y1 - sy) < tolerance - and abs(x2 - ex) < tolerance - and abs(y2 - ey) < tolerance - ) - match_rev = ( - abs(x1 - ex) < tolerance - and abs(y1 - ey) < tolerance - and abs(x2 - sx) < tolerance - and abs(y2 - sy) < tolerance - ) - - if match_fwd or match_rev: - del sch_data[i] - with open(schematic_path, "w", encoding="utf-8") as f: - f.write(sexpdata.dumps(sch_data)) - logger.info(f"Deleted wire from {start_point} to {end_point}") - return True - - logger.warning(f"No matching wire found for {start_point} to {end_point}") - return False - - except Exception as e: - logger.error(f"Error deleting wire: {e}") - import traceback - - logger.error(traceback.format_exc()) - return False - - @staticmethod - def delete_label( - schematic_path: Path, - net_name: str, - position: Optional[List[float]] = None, - tolerance: float = 0.5, - ) -> bool: - """ - Delete a net label from the schematic by name (and optionally position). - - Args: - schematic_path: Path to .kicad_sch file - net_name: Net label text to match - position: Optional [x, y] to disambiguate when multiple labels share a name - tolerance: Maximum coordinate difference to consider a match (mm) - - Returns: - True if a label was found and removed, False otherwise - """ - try: - with open(schematic_path, "r", encoding="utf-8") as f: - sch_content = f.read() - - sch_data = sexpdata.loads(sch_content) - - for i, item in enumerate(sch_data): - if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL): - continue - - # Second element is the label text - if len(item) < 2 or item[1] != net_name: - continue - - if position is not None: - # Find (at x y ...) sub-expression and check coordinates - at_entry = next( - ( - p - for p in item[1:] - if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_AT - ), - None, - ) - if at_entry is None: - continue - lx, ly = float(at_entry[1]), float(at_entry[2]) - if not ( - abs(lx - position[0]) < tolerance and abs(ly - position[1]) < tolerance - ): - continue - - del sch_data[i] - with open(schematic_path, "w", encoding="utf-8") as f: - f.write(sexpdata.dumps(sch_data)) - logger.info(f"Deleted label '{net_name}'") - return True - - logger.warning(f"No matching label found for '{net_name}'") - return False - - except Exception as e: - logger.error(f"Error deleting label: {e}") - import traceback - - logger.error(traceback.format_exc()) - return False - - @staticmethod - def create_orthogonal_path( - start: List[float], end: List[float], prefer_horizontal_first: bool = True - ) -> List[List[float]]: - """ - Create an orthogonal (right-angle) path between two points - - Args: - start: [x, y] start coordinates - end: [x, y] end coordinates - prefer_horizontal_first: If True, route horizontally first, else vertically first - - Returns: - List of points defining the path: [start, corner, end] - """ - x1, y1 = start - x2, y2 = end - - if prefer_horizontal_first: - # Route: start → (x2, y1) → end - corner = [x2, y1] - else: - # Route: start → (x1, y2) → end - corner = [x1, y2] - - # If start and end are already aligned, return direct path - if x1 == x2 or y1 == y2: - return [start, end] - - return [start, corner, end] - - @staticmethod - def add_hierarchical_label( - schematic_path: Path, - text: str, - position: List[float], - shape: str = "bidirectional", - orientation: int = 0, - ) -> bool: - """Add a hierarchical label to a sub-sheet schematic.""" - try: - label_text = _make_hierarchical_label_text(text, position, shape, orientation) - _text_insert(schematic_path, label_text) - logger.info(f"Added hierarchical_label '{text}' at {position} shape={shape}") - return True - except Exception as e: - logger.error(f"Error adding hierarchical label: {e}") - import traceback - - logger.error(traceback.format_exc()) - return False - - @staticmethod - def add_sheet_pin( - content: str, - sheet_name: str, - pin_name: str, - pin_type: str, - position: List[float], - orientation: int = 0, - ) -> Tuple[str, bool]: - """Insert a sheet pin into the named sheet block in the parent schematic. - - Returns (modified_content, success). - """ - lines = content.split("\n") - sheetname_pattern = re.compile( - r'\(property\s+"Sheetname"\s+"' + re.escape(sheet_name) + r'"' - ) - sheet_block_pattern = re.compile(r"^\t\(sheet\b") - - # Find the sheet block that contains the target Sheetname property - i = 0 - while i < len(lines): - if sheet_block_pattern.match(lines[i]): - # Walk forward to find closing paren of this block - depth = sum(1 for c in lines[i] if c == "(") - sum(1 for c in lines[i] if c == ")") - j = i + 1 - found_name = False - while j < len(lines) and depth > 0: - if sheetname_pattern.search(lines[j]): - found_name = True - 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 # index of closing ")" line of the sheet block - - if found_name: - # Insert pin text before the closing paren of the sheet block - pin_text = _make_sheet_pin_text(pin_name, pin_type, position, orientation) - pin_lines = pin_text.rstrip("\n").split("\n") - for offset, line in enumerate(pin_lines): - lines.insert(b_end + offset, line) - logger.info(f"Added sheet pin '{pin_name}' to sheet '{sheet_name}'") - return "\n".join(lines), True - - i = b_end + 1 - continue - i += 1 - - return content, False - - -if __name__ == "__main__": - # Test wire creation - import shutil - import sys - from pathlib import Path - - sys.path.insert(0, str(Path(__file__).parent.parent)) - - print("=" * 80) - print("WIRE MANAGER TEST") - print("=" * 80) - - # Create test schematic (cross-platform temp directory) - test_path = Path(tempfile.gettempdir()) / "test_wire_manager.kicad_sch" - template_path = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" - - shutil.copy(template_path, test_path) - print(f"\n✓ Created test schematic: {test_path}") - - # Test 1: Add simple wire - print("\n[1/5] Testing simple wire creation...") - success = WireManager.add_wire(test_path, [50.8, 50.8], [101.6, 50.8]) - print(f" {'✓' if success else '✗'} Simple wire: {success}") - - # Test 2: Add orthogonal wire - print("\n[2/5] Testing orthogonal wire...") - path = WireManager.create_orthogonal_path([50.8, 60.96], [101.6, 88.9]) - print(f" Orthogonal path: {path}") - success = WireManager.add_polyline_wire(test_path, path) - print(f" {'✓' if success else '✗'} Polyline wire: {success}") - - # Test 3: Add label - print("\n[3/5] Testing label creation...") - success = WireManager.add_label(test_path, "VCC", [76.2, 50.8]) - print(f" {'✓' if success else '✗'} Label: {success}") - - # Test 4: Add junction - print("\n[4/5] Testing junction creation...") - success = WireManager.add_junction(test_path, [76.2, 50.8]) - print(f" {'✓' if success else '✗'} Junction: {success}") - - # Test 5: Add no-connect - print("\n[5/5] Testing no-connect creation...") - success = WireManager.add_no_connect(test_path, [127, 50.8]) - print(f" {'✓' if success else '✗'} No-connect: {success}") - - # Verify with kicad-skip - print("\n[Verification] Loading with kicad-skip...") - try: - from skip import Schematic - - sch = Schematic(str(test_path)) - wire_count = len(list(sch.wire)) if hasattr(sch, "wire") else 0 - print(f" ✓ Loaded successfully") - print(f" ✓ Wire count: {wire_count}") - except Exception as e: - print(f" ✗ Failed: {e}") - - print("\n" + "=" * 80) - print(f"Test schematic saved: {test_path}") - print("Open in KiCad to verify visual appearance!") - print("=" * 80) +""" +Wire Manager for KiCad Schematics + +Handles wire creation using S-expression manipulation, similar to dynamic symbol loading. +kicad-skip's wire API doesn't support creating wires with standard parameters, so we +manipulate the .kicad_sch file directly. +""" + +import logging +import math +import re +import tempfile +import uuid +from pathlib import Path +from typing import Any, List, Optional, Tuple + +import sexpdata +from sexpdata import Symbol + +logger = logging.getLogger("kicad_interface") + +# Module-level Symbol constants — avoids repeated allocation on every call +_SYM_WIRE = Symbol("wire") +_SYM_PTS = Symbol("pts") +_SYM_XY = Symbol("xy") +_SYM_AT = Symbol("at") +_SYM_LABEL = Symbol("label") +_SYM_STROKE = Symbol("stroke") +_SYM_WIDTH = Symbol("width") +_SYM_TYPE = Symbol("type") +_SYM_UUID = Symbol("uuid") +_SYM_SHEET_INSTANCES = Symbol("sheet_instances") + + +def _find_insertion_point(content: str) -> int: + """Find the right place to insert new elements in a .kicad_sch file. + + Looks for (sheet_instances (KiCad 8) first, falls back to inserting + before the final closing paren (KiCad 9+). + """ + marker = "(sheet_instances" + pos = content.rfind(marker) + if pos != -1: + return pos + pos = content.rfind(")") + if pos == -1: + raise ValueError("Could not find insertion point in schematic") + return pos + + +def _text_insert(file_path: Path, sexp_text: str) -> bool: + """Insert S-expression text into a .kicad_sch file preserving formatting.""" + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + insert_at = _find_insertion_point(content) + content = content[:insert_at] + sexp_text + content[insert_at:] + + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + return True + + +def _make_hierarchical_label_text( + text: str, + position: List[float], + shape: str = "bidirectional", + orientation: int = 0, +) -> str: + """Generate a hierarchical_label S-expression as formatted text. + + orientation: 0=right (label points right, justify left), + 180=left (label points left, justify right), + 90/270=vertical. + """ + uid = str(uuid.uuid4()) + justify = "right" if orientation == 180 else "left" + return ( + f'\t(hierarchical_label "{text}"\n' + f"\t\t(shape {shape})\n" + f"\t\t(at {position[0]} {position[1]} {orientation})\n" + f"\t\t(effects\n" + f"\t\t\t(font\n" + f"\t\t\t\t(size 1.27 1.27)\n" + f"\t\t\t)\n" + f"\t\t\t(justify {justify})\n" + f"\t\t)\n" + f'\t\t(uuid "{uid}")\n' + f"\t)\n" + ) + + +def _make_sheet_pin_text( + pin_name: str, + pin_type: str, + position: List[float], + orientation: int = 0, +) -> str: + """Generate a sheet pin S-expression as formatted text (indented for inside sheet block). + + orientation: 0=right side of sheet box, 180=left side. + """ + uid = str(uuid.uuid4()) + justify = "left" if orientation == 0 else "right" + return ( + f'\t\t(pin "{pin_name}" {pin_type}\n' + f"\t\t\t(at {position[0]} {position[1]} {orientation})\n" + f'\t\t\t(uuid "{uid}")\n' + f"\t\t\t(effects\n" + f"\t\t\t\t(font\n" + f"\t\t\t\t\t(size 1.27 1.27)\n" + f"\t\t\t\t)\n" + f"\t\t\t\t(justify {justify})\n" + f"\t\t\t)\n" + f"\t\t)\n" + ) + + +class WireManager: + """Manage wires in KiCad schematics using S-expression manipulation""" + + @staticmethod + def add_wire( + schematic_path: Path, + start_point: List[float], + end_point: List[float], + stroke_width: float = 0, + stroke_type: str = "default", + ) -> bool: + """ + Add a wire to the schematic using S-expression manipulation + + Args: + schematic_path: Path to .kicad_sch file + start_point: [x, y] coordinates for wire start + end_point: [x, y] coordinates for wire end + stroke_width: Wire width (default 0 for standard) + stroke_type: Stroke type (default, solid, dashed, etc.) + + Returns: + True if successful, False otherwise + """ + try: + # Read schematic + with open(schematic_path, "r", encoding="utf-8") as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + # Break any existing wire that passes through a new endpoint (T-junction support) + for pt in (start_point, end_point): + splits = WireManager._break_wires_at_point(sch_data, pt) + if splits: + logger.info(f"Broke {splits} wire(s) at new wire endpoint {pt}") + + # Create wire S-expression + # Format: (wire (pts (xy x1 y1) (xy x2 y2)) (stroke (width N) (type default)) (uuid ...)) + wire_sexp = WireManager._make_wire_sexp( + start_point, end_point, stroke_width, stroke_type + ) + + # Find insertion point (before sheet_instances) + sheet_instances_index = None + for i, item in enumerate(sch_data): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: + sheet_instances_index = i + break + + if sheet_instances_index is None: + logger.error("No sheet_instances section found in schematic") + return False + + # Insert wire before sheet_instances + sch_data.insert(sheet_instances_index, wire_sexp) + logger.info(f"Injected wire from {start_point} to {end_point}") + + # Write back + with open(schematic_path, "w", encoding="utf-8") as f: + output = sexpdata.dumps(sch_data) + f.write(output) + + logger.info(f"Successfully added wire to {schematic_path.name}") + return True + + except Exception as e: + logger.error(f"Error adding wire: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False + + @staticmethod + def add_polyline_wire( + schematic_path: Path, + points: List[List[float]], + stroke_width: float = 0, + stroke_type: str = "default", + ) -> bool: + """ + Add a multi-segment wire (polyline) to the schematic + + Args: + schematic_path: Path to .kicad_sch file + points: List of [x, y] coordinates for each point in the path + stroke_width: Wire width + stroke_type: Stroke type + + Returns: + True if successful, False otherwise + """ + try: + if len(points) < 2: + logger.error("Polyline requires at least 2 points") + return False + + # Read schematic + with open(schematic_path, "r", encoding="utf-8") as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + # Break any existing wire at the outer endpoints of the new path + for pt in (points[0], points[-1]): + splits = WireManager._break_wires_at_point(sch_data, pt) + if splits: + logger.info(f"Broke {splits} wire(s) at new polyline endpoint {pt}") + + # KiCAD wire elements only support exactly 2 pts each. + # Split N waypoints into N-1 individual wire segments. + wire_sexps = [ + WireManager._make_wire_sexp(points[i], points[i + 1], stroke_width, stroke_type) + for i in range(len(points) - 1) + ] + + # Find insertion point + sheet_instances_index = None + for i, item in enumerate(sch_data): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: + sheet_instances_index = i + break + + if sheet_instances_index is None: + logger.error("No sheet_instances section found in schematic") + return False + + # Insert all segments (in reverse so order is preserved after inserts) + for wire_sexp in reversed(wire_sexps): + sch_data.insert(sheet_instances_index, wire_sexp) + logger.info( + f"Injected {len(wire_sexps)} wire segments for {len(points)}-point polyline" + ) + + # Write back + with open(schematic_path, "w", encoding="utf-8") as f: + output = sexpdata.dumps(sch_data) + f.write(output) + + logger.info(f"Successfully added polyline wire to {schematic_path.name}") + return True + + except Exception as e: + logger.error(f"Error adding polyline wire: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False + + @staticmethod + def add_label( + schematic_path: Path, + text: str, + position: List[float], + label_type: str = "label", + orientation: int = 0, + ) -> bool: + """ + Add a net label to the schematic + + Args: + schematic_path: Path to .kicad_sch file + text: Label text (net name) + position: [x, y] coordinates for label + label_type: Type of label ('label', 'global_label', 'hierarchical_label') + orientation: Rotation angle (0, 90, 180, 270) + + Returns: + True if successful, False otherwise + """ + try: + # Read schematic + with open(schematic_path, "r", encoding="utf-8") as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + # Create label S-expression + # Format: (label "TEXT" (at x y angle) (effects (font (size 1.27 1.27)))) + label_sexp = [ + Symbol(label_type), + text, + [Symbol("at"), position[0], position[1], orientation], + [Symbol("fields_autoplaced"), Symbol("yes")], + [ + Symbol("effects"), + [Symbol("font"), [Symbol("size"), 1.27, 1.27]], + [Symbol("justify"), Symbol("left"), Symbol("bottom")], + ], + [Symbol("uuid"), str(uuid.uuid4())], + ] + + # Find insertion point + sheet_instances_index = None + for i, item in enumerate(sch_data): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: + sheet_instances_index = i + break + + if sheet_instances_index is None: + logger.error("No sheet_instances section found in schematic") + return False + + # Insert label + sch_data.insert(sheet_instances_index, label_sexp) + logger.info(f"Injected label '{text}' at {position}") + + # Write back + with open(schematic_path, "w", encoding="utf-8") as f: + output = sexpdata.dumps(sch_data) + f.write(output) + + logger.info(f"Successfully added label to {schematic_path.name}") + return True + + except Exception as e: + logger.error(f"Error adding label: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False + + @staticmethod + def _parse_wire( + wire_item: Any, + ) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]: + """ + Parse a wire S-expression item in a single pass. + Returns ((x1,y1), (x2,y2), stroke_width, stroke_type), or None if not a valid wire. + """ + if not (isinstance(wire_item, list) and len(wire_item) >= 2 and wire_item[0] == _SYM_WIRE): + return None + start = end = None + stroke_width: float = 0 + stroke_type: str = "default" + for part in wire_item[1:]: + if not isinstance(part, list) or not part: + continue + tag = part[0] + if tag == _SYM_PTS: + found: List[Tuple[float, float]] = [] + for p in part[1:]: + if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_XY: + found.append((float(p[1]), float(p[2]))) + if len(found) == 2: + break + if len(found) == 2: + start, end = found[0], found[1] + elif tag == _SYM_STROKE: + for sp in part[1:]: + if isinstance(sp, list) and len(sp) >= 2: + if sp[0] == _SYM_WIDTH: + stroke_width = sp[1] + elif sp[0] == _SYM_TYPE: + stroke_type = str(sp[1]) + if start is not None and end is not None: + return start, end, stroke_width, stroke_type + return None + + @staticmethod + def _point_strictly_on_wire( + px: float, + py: float, + x1: float, + y1: float, + x2: float, + y2: float, + eps: float = 1e-6, + ) -> bool: + """ + Return True if (px, py) lies strictly between (x1,y1) and (x2,y2) + on a horizontal or vertical wire segment (not at either endpoint). + """ + if abs(y1 - y2) < eps: # horizontal wire + if abs(py - y1) > eps: + return False + lo, hi = min(x1, x2), max(x1, x2) + return lo + eps < px < hi - eps + if abs(x1 - x2) < eps: # vertical wire + if abs(px - x1) > eps: + return False + lo, hi = min(y1, y2), max(y1, y2) + return lo + eps < py < hi - eps + return False + + @staticmethod + def _make_wire_sexp( + start: List[float], + end: List[float], + stroke_width: float = 0, + stroke_type: str = "default", + ) -> list: + return [ + _SYM_WIRE, + [_SYM_PTS, [_SYM_XY, start[0], start[1]], [_SYM_XY, end[0], end[1]]], + [_SYM_STROKE, [_SYM_WIDTH, stroke_width], [_SYM_TYPE, Symbol(stroke_type)]], + [_SYM_UUID, str(uuid.uuid4())], + ] + + @staticmethod + def _break_wires_at_point(sch_data: list, position: List[float]) -> int: + """ + Split any wire segment that passes through *position* as a strict + midpoint (i.e. position is not an existing endpoint). Mirrors + KiCAD's SCH_LINE_WIRE_BUS_TOOL::BreakSegments behaviour. + + Returns the number of wires split. + """ + px, py = float(position[0]), float(position[1]) + splits = 0 + i = 0 + while i < len(sch_data): + parsed = WireManager._parse_wire(sch_data[i]) + if parsed is not None: + (x1, y1), (x2, y2), stroke_width, stroke_type = parsed + if WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2): + seg_a = WireManager._make_wire_sexp( + [x1, y1], [px, py], stroke_width, stroke_type + ) + seg_b = WireManager._make_wire_sexp( + [px, py], [x2, y2], stroke_width, stroke_type + ) + sch_data[i : i + 1] = [seg_a, seg_b] + logger.info(f"Split wire ({x1},{y1})->({x2},{y2}) at ({px},{py})") + splits += 1 + i += 2 # skip the two new segments + continue + i += 1 + return splits + + @staticmethod + def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool: + """ + Add a junction (connection dot) to the schematic. + + Mirrors KiCAD's AddJunction behaviour: any wire whose interior passes + through *position* is split into two segments at that point so that + the BFS-based get_wire_connections tool can traverse the T/X branch. + + Args: + schematic_path: Path to .kicad_sch file + position: [x, y] coordinates for junction + diameter: Junction diameter (0 for default) + + Returns: + True if successful, False otherwise + """ + try: + # Read schematic + with open(schematic_path, "r", encoding="utf-8") as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + # Split any wire that passes through the junction as a midpoint + # (mirrors KiCAD's AddJunction / BreakSegments behaviour) + splits = WireManager._break_wires_at_point(sch_data, position) + if splits: + logger.info(f"Broke {splits} wire(s) at junction position {position}") + + # Create junction S-expression + # Format: (junction (at x y) (diameter 0) (color 0 0 0 0) (uuid ...)) + junction_sexp = [ + Symbol("junction"), + [Symbol("at"), position[0], position[1]], + [Symbol("diameter"), diameter], + [Symbol("color"), 0, 0, 0, 0], + [Symbol("uuid"), str(uuid.uuid4())], + ] + + # Find insertion point + sheet_instances_index = None + for i, item in enumerate(sch_data): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: + sheet_instances_index = i + break + + if sheet_instances_index is None: + logger.error("No sheet_instances section found in schematic") + return False + + # Insert junction + sch_data.insert(sheet_instances_index, junction_sexp) + logger.info(f"Injected junction at {position}") + + # Write back + with open(schematic_path, "w", encoding="utf-8") as f: + output = sexpdata.dumps(sch_data) + f.write(output) + + logger.info(f"Successfully added junction to {schematic_path.name}") + return True + + except Exception as e: + logger.error(f"Error adding junction: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False + + @staticmethod + def add_no_connect(schematic_path: Path, position: List[float]) -> bool: + """ + Add a no-connect flag to the schematic + + Args: + schematic_path: Path to .kicad_sch file + position: [x, y] coordinates for no-connect flag + + Returns: + True if successful, False otherwise + """ + try: + # Read schematic + with open(schematic_path, "r", encoding="utf-8") as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + # Create no_connect S-expression + # Format: (no_connect (at x y) (uuid ...)) + no_connect_sexp = [ + Symbol("no_connect"), + [Symbol("at"), position[0], position[1]], + [Symbol("uuid"), str(uuid.uuid4())], + ] + + # Find insertion point + sheet_instances_index = None + for i, item in enumerate(sch_data): + if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES: + sheet_instances_index = i + break + + if sheet_instances_index is None: + logger.error("No sheet_instances section found in schematic") + return False + + # Insert no_connect + sch_data.insert(sheet_instances_index, no_connect_sexp) + logger.info(f"Injected no-connect at {position}") + + # Write back + with open(schematic_path, "w", encoding="utf-8") as f: + output = sexpdata.dumps(sch_data) + f.write(output) + + logger.info(f"Successfully added no-connect to {schematic_path.name}") + return True + + except Exception as e: + logger.error(f"Error adding no-connect: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False + + @staticmethod + def delete_wire( + schematic_path: Path, + start_point: List[float], + end_point: List[float], + tolerance: float = 0.5, + ) -> bool: + """ + Delete a wire from the schematic matching given start/end coordinates. + + Args: + schematic_path: Path to .kicad_sch file + start_point: [x, y] coordinates for wire start + end_point: [x, y] coordinates for wire end + tolerance: Maximum coordinate difference to consider a match (mm) + + Returns: + True if a wire was found and removed, False otherwise + """ + try: + with open(schematic_path, "r", encoding="utf-8") as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + sx, sy = start_point + ex, ey = end_point + + for i, item in enumerate(sch_data): + if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE): + continue + + # Extract pts from the wire s-expression + pts_list = None + for part in item[1:]: + if isinstance(part, list) and len(part) > 0 and part[0] == _SYM_PTS: + pts_list = part + break + + if pts_list is None: + continue + + xy_points = [ + p + for p in pts_list[1:] + if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_XY + ] + if len(xy_points) < 2: + continue + + x1, y1 = float(xy_points[0][1]), float(xy_points[0][2]) + x2, y2 = float(xy_points[-1][1]), float(xy_points[-1][2]) + + match_fwd = ( + abs(x1 - sx) < tolerance + and abs(y1 - sy) < tolerance + and abs(x2 - ex) < tolerance + and abs(y2 - ey) < tolerance + ) + match_rev = ( + abs(x1 - ex) < tolerance + and abs(y1 - ey) < tolerance + and abs(x2 - sx) < tolerance + and abs(y2 - sy) < tolerance + ) + + if match_fwd or match_rev: + del sch_data[i] + with open(schematic_path, "w", encoding="utf-8") as f: + f.write(sexpdata.dumps(sch_data)) + logger.info(f"Deleted wire from {start_point} to {end_point}") + return True + + logger.warning(f"No matching wire found for {start_point} to {end_point}") + return False + + except Exception as e: + logger.error(f"Error deleting wire: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False + + @staticmethod + def delete_label( + schematic_path: Path, + net_name: str, + position: Optional[List[float]] = None, + tolerance: float = 0.5, + ) -> bool: + """ + Delete a net label from the schematic by name (and optionally position). + + Args: + schematic_path: Path to .kicad_sch file + net_name: Net label text to match + position: Optional [x, y] to disambiguate when multiple labels share a name + tolerance: Maximum coordinate difference to consider a match (mm) + + Returns: + True if a label was found and removed, False otherwise + """ + try: + with open(schematic_path, "r", encoding="utf-8") as f: + sch_content = f.read() + + sch_data = sexpdata.loads(sch_content) + + for i, item in enumerate(sch_data): + if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL): + continue + + # Second element is the label text + if len(item) < 2 or item[1] != net_name: + continue + + if position is not None: + # Find (at x y ...) sub-expression and check coordinates + at_entry = next( + ( + p + for p in item[1:] + if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_AT + ), + None, + ) + if at_entry is None: + continue + lx, ly = float(at_entry[1]), float(at_entry[2]) + if not ( + abs(lx - position[0]) < tolerance and abs(ly - position[1]) < tolerance + ): + continue + + del sch_data[i] + with open(schematic_path, "w", encoding="utf-8") as f: + f.write(sexpdata.dumps(sch_data)) + logger.info(f"Deleted label '{net_name}'") + return True + + logger.warning(f"No matching label found for '{net_name}'") + return False + + except Exception as e: + logger.error(f"Error deleting label: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False + + @staticmethod + def create_orthogonal_path( + start: List[float], end: List[float], prefer_horizontal_first: bool = True + ) -> List[List[float]]: + """ + Create an orthogonal (right-angle) path between two points + + Args: + start: [x, y] start coordinates + end: [x, y] end coordinates + prefer_horizontal_first: If True, route horizontally first, else vertically first + + Returns: + List of points defining the path: [start, corner, end] + """ + x1, y1 = start + x2, y2 = end + + if prefer_horizontal_first: + # Route: start → (x2, y1) → end + corner = [x2, y1] + else: + # Route: start → (x1, y2) → end + corner = [x1, y2] + + # If start and end are already aligned, return direct path + if x1 == x2 or y1 == y2: + return [start, end] + + return [start, corner, end] + + @staticmethod + def add_hierarchical_label( + schematic_path: Path, + text: str, + position: List[float], + shape: str = "bidirectional", + orientation: int = 0, + ) -> bool: + """Add a hierarchical label to a sub-sheet schematic.""" + try: + label_text = _make_hierarchical_label_text(text, position, shape, orientation) + _text_insert(schematic_path, label_text) + logger.info(f"Added hierarchical_label '{text}' at {position} shape={shape}") + return True + except Exception as e: + logger.error(f"Error adding hierarchical label: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False + + @staticmethod + def add_sheet_pin( + content: str, + sheet_name: str, + pin_name: str, + pin_type: str, + position: List[float], + orientation: int = 0, + ) -> Tuple[str, bool]: + """Insert a sheet pin into the named sheet block in the parent schematic. + + Returns (modified_content, success). + """ + lines = content.split("\n") + sheetname_pattern = re.compile( + r'\(property\s+"Sheetname"\s+"' + re.escape(sheet_name) + r'"' + ) + sheet_block_pattern = re.compile(r"^\t\(sheet\b") + + # Find the sheet block that contains the target Sheetname property + i = 0 + while i < len(lines): + if sheet_block_pattern.match(lines[i]): + # Walk forward to find closing paren of this block + depth = sum(1 for c in lines[i] if c == "(") - sum(1 for c in lines[i] if c == ")") + j = i + 1 + found_name = False + while j < len(lines) and depth > 0: + if sheetname_pattern.search(lines[j]): + found_name = True + 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 # index of closing ")" line of the sheet block + + if found_name: + # Insert pin text before the closing paren of the sheet block + pin_text = _make_sheet_pin_text(pin_name, pin_type, position, orientation) + pin_lines = pin_text.rstrip("\n").split("\n") + for offset, line in enumerate(pin_lines): + lines.insert(b_end + offset, line) + logger.info(f"Added sheet pin '{pin_name}' to sheet '{sheet_name}'") + return "\n".join(lines), True + + i = b_end + 1 + continue + i += 1 + + return content, False + + +if __name__ == "__main__": + # Test wire creation + import shutil + import sys + from pathlib import Path + + sys.path.insert(0, str(Path(__file__).parent.parent)) + + print("=" * 80) + print("WIRE MANAGER TEST") + print("=" * 80) + + # Create test schematic (cross-platform temp directory) + test_path = Path(tempfile.gettempdir()) / "test_wire_manager.kicad_sch" + template_path = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" + + shutil.copy(template_path, test_path) + print(f"\n✓ Created test schematic: {test_path}") + + # Test 1: Add simple wire + print("\n[1/5] Testing simple wire creation...") + success = WireManager.add_wire(test_path, [50.8, 50.8], [101.6, 50.8]) + print(f" {'✓' if success else '✗'} Simple wire: {success}") + + # Test 2: Add orthogonal wire + print("\n[2/5] Testing orthogonal wire...") + path = WireManager.create_orthogonal_path([50.8, 60.96], [101.6, 88.9]) + print(f" Orthogonal path: {path}") + success = WireManager.add_polyline_wire(test_path, path) + print(f" {'✓' if success else '✗'} Polyline wire: {success}") + + # Test 3: Add label + print("\n[3/5] Testing label creation...") + success = WireManager.add_label(test_path, "VCC", [76.2, 50.8]) + print(f" {'✓' if success else '✗'} Label: {success}") + + # Test 4: Add junction + print("\n[4/5] Testing junction creation...") + success = WireManager.add_junction(test_path, [76.2, 50.8]) + print(f" {'✓' if success else '✗'} Junction: {success}") + + # Test 5: Add no-connect + print("\n[5/5] Testing no-connect creation...") + success = WireManager.add_no_connect(test_path, [127, 50.8]) + print(f" {'✓' if success else '✗'} No-connect: {success}") + + # Verify with kicad-skip + print("\n[Verification] Loading with kicad-skip...") + try: + from skip import Schematic + + sch = Schematic(str(test_path)) + wire_count = len(list(sch.wire)) if hasattr(sch, "wire") else 0 + print(f" ✓ Loaded successfully") + print(f" ✓ Wire count: {wire_count}") + except Exception as e: + print(f" ✗ Failed: {e}") + + print("\n" + "=" * 80) + print(f"Test schematic saved: {test_path}") + print("Open in KiCad to verify visual appearance!") + print("=" * 80) diff --git a/python/kicad_api/__init__.py b/python/kicad_api/__init__.py index ee997dc..b0638f7 100644 --- a/python/kicad_api/__init__.py +++ b/python/kicad_api/__init__.py @@ -1,27 +1,27 @@ -""" -KiCAD API Abstraction Layer - -This module provides a unified interface to KiCAD's Python APIs, -supporting both the legacy SWIG bindings and the new IPC API. - -Usage: - from kicad_api import create_backend - - # Auto-detect best available backend - backend = create_backend() - - # Or specify explicitly - backend = create_backend('ipc') # Use IPC API - backend = create_backend('swig') # Use legacy SWIG - - # Connect and use - if backend.connect(): - board = backend.get_board() - board.set_size(100, 80) -""" - -from kicad_api.base import KiCADBackend -from kicad_api.factory import create_backend - -__all__ = ["create_backend", "KiCADBackend"] -__version__ = "2.0.0-alpha.1" +""" +KiCAD API Abstraction Layer + +This module provides a unified interface to KiCAD's Python APIs, +supporting both the legacy SWIG bindings and the new IPC API. + +Usage: + from kicad_api import create_backend + + # Auto-detect best available backend + backend = create_backend() + + # Or specify explicitly + backend = create_backend('ipc') # Use IPC API + backend = create_backend('swig') # Use legacy SWIG + + # Connect and use + if backend.connect(): + board = backend.get_board() + board.set_size(100, 80) +""" + +from kicad_api.base import KiCADBackend +from kicad_api.factory import create_backend + +__all__ = ["create_backend", "KiCADBackend"] +__version__ = "2.0.0-alpha.1" diff --git a/python/kicad_api/base.py b/python/kicad_api/base.py index 2bdbd14..97eca43 100644 --- a/python/kicad_api/base.py +++ b/python/kicad_api/base.py @@ -1,293 +1,293 @@ -""" -Abstract base class for KiCAD API backends - -Defines the interface that all KiCAD backends must implement. -""" - -import logging -from abc import ABC, abstractmethod -from pathlib import Path -from typing import Any, Dict, List, Optional - -logger = logging.getLogger(__name__) - - -class KiCADBackend(ABC): - """Abstract base class for KiCAD API backends""" - - @abstractmethod - def connect(self) -> bool: - """ - Connect to KiCAD - - Returns: - True if connection successful, False otherwise - """ - pass - - @abstractmethod - def disconnect(self) -> None: - """Disconnect from KiCAD and clean up resources""" - pass - - @abstractmethod - def is_connected(self) -> bool: - """ - Check if currently connected to KiCAD - - Returns: - True if connected, False otherwise - """ - pass - - @abstractmethod - def get_version(self) -> str: - """ - Get KiCAD version - - Returns: - Version string (e.g., "9.0.0") - """ - pass - - # Project Operations - @abstractmethod - def create_project(self, path: Path, name: str) -> Dict[str, Any]: - """ - Create a new KiCAD project - - Args: - path: Directory path for the project - name: Project name - - Returns: - Dictionary with project info - """ - pass - - @abstractmethod - def open_project(self, path: Path) -> Dict[str, Any]: - """ - Open an existing KiCAD project - - Args: - path: Path to .kicad_pro file - - Returns: - Dictionary with project info - """ - pass - - @abstractmethod - def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]: - """ - Save the current project - - Args: - path: Optional new path to save to - - Returns: - Dictionary with save status - """ - pass - - @abstractmethod - def close_project(self) -> None: - """Close the current project""" - pass - - # Board Operations - @abstractmethod - def get_board(self) -> "BoardAPI": - """ - Get board API for current project - - Returns: - BoardAPI instance - """ - pass - - -class BoardAPI(ABC): - """Abstract interface for board operations""" - - @abstractmethod - def set_size(self, width: float, height: float, unit: str = "mm") -> bool: - """ - Set board size - - Args: - width: Board width - height: Board height - unit: Unit of measurement ("mm" or "in") - - Returns: - True if successful - """ - pass - - @abstractmethod - def get_size(self) -> Dict[str, Any]: - """ - Get current board size - - Returns: - Dictionary with width, height, unit - """ - pass - - @abstractmethod - def add_layer(self, layer_name: str, layer_type: str) -> bool: - """ - Add a layer to the board - - Args: - layer_name: Name of the layer - layer_type: Type ("copper", "technical", "user") - - Returns: - True if successful - """ - pass - - @abstractmethod - def list_components(self) -> List[Dict[str, Any]]: - """ - List all components on the board - - Returns: - List of component dictionaries - """ - pass - - @abstractmethod - def place_component( - self, - reference: str, - footprint: str, - x: float, - y: float, - rotation: float = 0, - layer: str = "F.Cu", - value: str = "", - ) -> bool: - """ - Place a component on the board - - Args: - reference: Component reference (e.g., "R1") - footprint: Footprint library path - x: X position (mm) - y: Y position (mm) - rotation: Rotation angle (degrees) - layer: Layer name - - Returns: - True if successful - """ - pass - - # Routing Operations - def add_track( - self, - start_x: float, - start_y: float, - end_x: float, - end_y: float, - width: float = 0.25, - layer: str = "F.Cu", - net_name: Optional[str] = None, - ) -> bool: - """ - Add a track (trace) to the board - - Args: - start_x: Start X position (mm) - start_y: Start Y position (mm) - end_x: End X position (mm) - end_y: End Y position (mm) - width: Track width (mm) - layer: Layer name - net_name: Optional net name - - Returns: - True if successful - """ - raise NotImplementedError() - - def add_via( - self, - x: float, - y: float, - diameter: float = 0.8, - drill: float = 0.4, - net_name: Optional[str] = None, - via_type: str = "through", - ) -> bool: - """ - Add a via to the board - - Args: - x: X position (mm) - y: Y position (mm) - diameter: Via diameter (mm) - drill: Drill diameter (mm) - net_name: Optional net name - via_type: Via type ("through", "blind", "micro") - - Returns: - True if successful - """ - raise NotImplementedError() - - # Transaction support for undo/redo - def begin_transaction(self, description: str = "MCP Operation") -> None: - """Begin a transaction for grouping operations.""" - pass # Optional - not all backends support this - - def commit_transaction(self, description: str = "MCP Operation") -> None: - """Commit the current transaction.""" - pass # Optional - - def rollback_transaction(self) -> None: - """Roll back the current transaction.""" - pass # Optional - - def save(self) -> bool: - """Save the board.""" - raise NotImplementedError() - - # Query operations - def get_tracks(self) -> List[Dict[str, Any]]: - """Get all tracks on the board.""" - raise NotImplementedError() - - def get_vias(self) -> List[Dict[str, Any]]: - """Get all vias on the board.""" - raise NotImplementedError() - - def get_nets(self) -> List[Dict[str, Any]]: - """Get all nets on the board.""" - raise NotImplementedError() - - def get_selection(self) -> List[Dict[str, Any]]: - """Get currently selected items.""" - raise NotImplementedError() - - -class BackendError(Exception): - """Base exception for backend errors""" - - pass - - -class ConnectionError(BackendError): - """Raised when connection to KiCAD fails""" - - pass - - -class APINotAvailableError(BackendError): - """Raised when required API is not available""" - - pass +""" +Abstract base class for KiCAD API backends + +Defines the interface that all KiCAD backends must implement. +""" + +import logging +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class KiCADBackend(ABC): + """Abstract base class for KiCAD API backends""" + + @abstractmethod + def connect(self) -> bool: + """ + Connect to KiCAD + + Returns: + True if connection successful, False otherwise + """ + pass + + @abstractmethod + def disconnect(self) -> None: + """Disconnect from KiCAD and clean up resources""" + pass + + @abstractmethod + def is_connected(self) -> bool: + """ + Check if currently connected to KiCAD + + Returns: + True if connected, False otherwise + """ + pass + + @abstractmethod + def get_version(self) -> str: + """ + Get KiCAD version + + Returns: + Version string (e.g., "9.0.0") + """ + pass + + # Project Operations + @abstractmethod + def create_project(self, path: Path, name: str) -> Dict[str, Any]: + """ + Create a new KiCAD project + + Args: + path: Directory path for the project + name: Project name + + Returns: + Dictionary with project info + """ + pass + + @abstractmethod + def open_project(self, path: Path) -> Dict[str, Any]: + """ + Open an existing KiCAD project + + Args: + path: Path to .kicad_pro file + + Returns: + Dictionary with project info + """ + pass + + @abstractmethod + def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]: + """ + Save the current project + + Args: + path: Optional new path to save to + + Returns: + Dictionary with save status + """ + pass + + @abstractmethod + def close_project(self) -> None: + """Close the current project""" + pass + + # Board Operations + @abstractmethod + def get_board(self) -> "BoardAPI": + """ + Get board API for current project + + Returns: + BoardAPI instance + """ + pass + + +class BoardAPI(ABC): + """Abstract interface for board operations""" + + @abstractmethod + def set_size(self, width: float, height: float, unit: str = "mm") -> bool: + """ + Set board size + + Args: + width: Board width + height: Board height + unit: Unit of measurement ("mm" or "in") + + Returns: + True if successful + """ + pass + + @abstractmethod + def get_size(self) -> Dict[str, Any]: + """ + Get current board size + + Returns: + Dictionary with width, height, unit + """ + pass + + @abstractmethod + def add_layer(self, layer_name: str, layer_type: str) -> bool: + """ + Add a layer to the board + + Args: + layer_name: Name of the layer + layer_type: Type ("copper", "technical", "user") + + Returns: + True if successful + """ + pass + + @abstractmethod + def list_components(self) -> List[Dict[str, Any]]: + """ + List all components on the board + + Returns: + List of component dictionaries + """ + pass + + @abstractmethod + def place_component( + self, + reference: str, + footprint: str, + x: float, + y: float, + rotation: float = 0, + layer: str = "F.Cu", + value: str = "", + ) -> bool: + """ + Place a component on the board + + Args: + reference: Component reference (e.g., "R1") + footprint: Footprint library path + x: X position (mm) + y: Y position (mm) + rotation: Rotation angle (degrees) + layer: Layer name + + Returns: + True if successful + """ + pass + + # Routing Operations + def add_track( + self, + start_x: float, + start_y: float, + end_x: float, + end_y: float, + width: float = 0.25, + layer: str = "F.Cu", + net_name: Optional[str] = None, + ) -> bool: + """ + Add a track (trace) to the board + + Args: + start_x: Start X position (mm) + start_y: Start Y position (mm) + end_x: End X position (mm) + end_y: End Y position (mm) + width: Track width (mm) + layer: Layer name + net_name: Optional net name + + Returns: + True if successful + """ + raise NotImplementedError() + + def add_via( + self, + x: float, + y: float, + diameter: float = 0.8, + drill: float = 0.4, + net_name: Optional[str] = None, + via_type: str = "through", + ) -> bool: + """ + Add a via to the board + + Args: + x: X position (mm) + y: Y position (mm) + diameter: Via diameter (mm) + drill: Drill diameter (mm) + net_name: Optional net name + via_type: Via type ("through", "blind", "micro") + + Returns: + True if successful + """ + raise NotImplementedError() + + # Transaction support for undo/redo + def begin_transaction(self, description: str = "MCP Operation") -> None: + """Begin a transaction for grouping operations.""" + pass # Optional - not all backends support this + + def commit_transaction(self, description: str = "MCP Operation") -> None: + """Commit the current transaction.""" + pass # Optional + + def rollback_transaction(self) -> None: + """Roll back the current transaction.""" + pass # Optional + + def save(self) -> bool: + """Save the board.""" + raise NotImplementedError() + + # Query operations + def get_tracks(self) -> List[Dict[str, Any]]: + """Get all tracks on the board.""" + raise NotImplementedError() + + def get_vias(self) -> List[Dict[str, Any]]: + """Get all vias on the board.""" + raise NotImplementedError() + + def get_nets(self) -> List[Dict[str, Any]]: + """Get all nets on the board.""" + raise NotImplementedError() + + def get_selection(self) -> List[Dict[str, Any]]: + """Get currently selected items.""" + raise NotImplementedError() + + +class BackendError(Exception): + """Base exception for backend errors""" + + pass + + +class ConnectionError(BackendError): + """Raised when connection to KiCAD fails""" + + pass + + +class APINotAvailableError(BackendError): + """Raised when required API is not available""" + + pass diff --git a/python/kicad_api/factory.py b/python/kicad_api/factory.py index 34d4764..fd656d4 100644 --- a/python/kicad_api/factory.py +++ b/python/kicad_api/factory.py @@ -1,195 +1,195 @@ -""" -Backend factory for creating appropriate KiCAD API backend - -Auto-detects available backends and provides fallback mechanism. -""" - -import logging -import os -from pathlib import Path -from typing import Optional - -from kicad_api.base import APINotAvailableError, KiCADBackend - -logger = logging.getLogger(__name__) - - -def create_backend(backend_type: Optional[str] = None) -> KiCADBackend: - """ - Create appropriate KiCAD backend - - Args: - backend_type: Backend to use: - - 'ipc': Use IPC API (recommended) - - 'swig': Use legacy SWIG bindings - - None or 'auto': Auto-detect (try IPC first, fall back to SWIG) - - Returns: - KiCADBackend instance - - Raises: - APINotAvailableError: If no backend is available - - Environment Variables: - KICAD_BACKEND: Override backend selection ('ipc', 'swig', or 'auto') - """ - # Check environment variable override - if backend_type is None: - backend_type = os.environ.get("KICAD_BACKEND", "auto").lower() - - logger.info(f"Requested backend: {backend_type}") - - # Try specific backend if requested - if backend_type == "ipc": - return _create_ipc_backend() - elif backend_type == "swig": - return _create_swig_backend() - elif backend_type == "auto": - return _auto_detect_backend() - else: - raise ValueError(f"Unknown backend type: {backend_type}") - - -def _create_ipc_backend() -> KiCADBackend: - """ - Create IPC backend - - Returns: - IPCBackend instance - - Raises: - APINotAvailableError: If kicad-python not available - """ - try: - from kicad_api.ipc_backend import IPCBackend - - logger.info("Creating IPC backend") - return IPCBackend() - except ImportError as e: - logger.error(f"IPC backend not available: {e}") - raise APINotAvailableError( - "IPC backend requires 'kicad-python' package. " "Install with: pip install kicad-python" - ) from e - - -def _create_swig_backend() -> KiCADBackend: - """ - Create SWIG backend - - Returns: - SWIGBackend instance - - Raises: - APINotAvailableError: If pcbnew not available - """ - try: - from kicad_api.swig_backend import SWIGBackend - - logger.info("Creating SWIG backend") - logger.warning( - "SWIG backend is DEPRECATED and will be removed in KiCAD 10.0. " - "Please migrate to IPC backend." - ) - return SWIGBackend() - except ImportError as e: - logger.error(f"SWIG backend not available: {e}") - raise APINotAvailableError( - "SWIG backend requires 'pcbnew' module. " "Ensure KiCAD Python module is in PYTHONPATH." - ) from e - - -def _auto_detect_backend() -> KiCADBackend: - """ - Auto-detect best available backend - - Priority: - 1. IPC API (if kicad-python available and KiCAD running) - 2. SWIG API (if pcbnew available) - - Returns: - Best available KiCADBackend - - Raises: - APINotAvailableError: If no backend available - """ - logger.info("Auto-detecting available KiCAD backend...") - - # Try IPC first (preferred) - try: - backend = _create_ipc_backend() - # Test connection - if backend.connect(): - logger.info("✓ IPC backend available and connected") - return backend - else: - logger.warning("IPC backend available but connection failed") - except (ImportError, APINotAvailableError) as e: - logger.debug(f"IPC backend not available: {e}") - - # Fall back to SWIG - try: - backend = _create_swig_backend() - logger.warning( - "Using deprecated SWIG backend. " "For best results, use IPC API with KiCAD running." - ) - return backend - except (ImportError, APINotAvailableError) as e: - logger.error(f"SWIG backend not available: {e}") - - # No backend available - raise APINotAvailableError( - "No KiCAD backend available. Please install either:\n" - " - kicad-python (recommended): pip install kicad-python\n" - " - Ensure KiCAD Python module (pcbnew) is in PYTHONPATH" - ) - - -def get_available_backends() -> dict: - """ - Check which backends are available - - Returns: - Dictionary with backend availability: - { - 'ipc': {'available': bool, 'version': str or None}, - 'swig': {'available': bool, 'version': str or None} - } - """ - results = {} - - # Check IPC (kicad-python uses 'kipy' module name) - try: - import kipy - - results["ipc"] = {"available": True, "version": getattr(kipy, "__version__", "unknown")} - except ImportError: - results["ipc"] = {"available": False, "version": None} - - # Check SWIG - try: - import pcbnew - - results["swig"] = {"available": True, "version": pcbnew.GetBuildVersion()} - except ImportError: - results["swig"] = {"available": False, "version": None} - - return results - - -if __name__ == "__main__": - # Quick diagnostic - import json - - print("KiCAD Backend Availability:") - print(json.dumps(get_available_backends(), indent=2)) - - print("\nAttempting to create backend...") - try: - backend = create_backend() - print(f"✓ Created backend: {type(backend).__name__}") - if backend.connect(): - print(f"✓ Connected to KiCAD: {backend.get_version()}") - else: - print("✗ Failed to connect to KiCAD") - except Exception as e: - print(f"✗ Error: {e}") +""" +Backend factory for creating appropriate KiCAD API backend + +Auto-detects available backends and provides fallback mechanism. +""" + +import logging +import os +from pathlib import Path +from typing import Optional + +from kicad_api.base import APINotAvailableError, KiCADBackend + +logger = logging.getLogger(__name__) + + +def create_backend(backend_type: Optional[str] = None) -> KiCADBackend: + """ + Create appropriate KiCAD backend + + Args: + backend_type: Backend to use: + - 'ipc': Use IPC API (recommended) + - 'swig': Use legacy SWIG bindings + - None or 'auto': Auto-detect (try IPC first, fall back to SWIG) + + Returns: + KiCADBackend instance + + Raises: + APINotAvailableError: If no backend is available + + Environment Variables: + KICAD_BACKEND: Override backend selection ('ipc', 'swig', or 'auto') + """ + # Check environment variable override + if backend_type is None: + backend_type = os.environ.get("KICAD_BACKEND", "auto").lower() + + logger.info(f"Requested backend: {backend_type}") + + # Try specific backend if requested + if backend_type == "ipc": + return _create_ipc_backend() + elif backend_type == "swig": + return _create_swig_backend() + elif backend_type == "auto": + return _auto_detect_backend() + else: + raise ValueError(f"Unknown backend type: {backend_type}") + + +def _create_ipc_backend() -> KiCADBackend: + """ + Create IPC backend + + Returns: + IPCBackend instance + + Raises: + APINotAvailableError: If kicad-python not available + """ + try: + from kicad_api.ipc_backend import IPCBackend + + logger.info("Creating IPC backend") + return IPCBackend() + except ImportError as e: + logger.error(f"IPC backend not available: {e}") + raise APINotAvailableError( + "IPC backend requires 'kicad-python' package. " "Install with: pip install kicad-python" + ) from e + + +def _create_swig_backend() -> KiCADBackend: + """ + Create SWIG backend + + Returns: + SWIGBackend instance + + Raises: + APINotAvailableError: If pcbnew not available + """ + try: + from kicad_api.swig_backend import SWIGBackend + + logger.info("Creating SWIG backend") + logger.warning( + "SWIG backend is DEPRECATED and will be removed in KiCAD 10.0. " + "Please migrate to IPC backend." + ) + return SWIGBackend() + except ImportError as e: + logger.error(f"SWIG backend not available: {e}") + raise APINotAvailableError( + "SWIG backend requires 'pcbnew' module. " "Ensure KiCAD Python module is in PYTHONPATH." + ) from e + + +def _auto_detect_backend() -> KiCADBackend: + """ + Auto-detect best available backend + + Priority: + 1. IPC API (if kicad-python available and KiCAD running) + 2. SWIG API (if pcbnew available) + + Returns: + Best available KiCADBackend + + Raises: + APINotAvailableError: If no backend available + """ + logger.info("Auto-detecting available KiCAD backend...") + + # Try IPC first (preferred) + try: + backend = _create_ipc_backend() + # Test connection + if backend.connect(): + logger.info("✓ IPC backend available and connected") + return backend + else: + logger.warning("IPC backend available but connection failed") + except (ImportError, APINotAvailableError) as e: + logger.debug(f"IPC backend not available: {e}") + + # Fall back to SWIG + try: + backend = _create_swig_backend() + logger.warning( + "Using deprecated SWIG backend. " "For best results, use IPC API with KiCAD running." + ) + return backend + except (ImportError, APINotAvailableError) as e: + logger.error(f"SWIG backend not available: {e}") + + # No backend available + raise APINotAvailableError( + "No KiCAD backend available. Please install either:\n" + " - kicad-python (recommended): pip install kicad-python\n" + " - Ensure KiCAD Python module (pcbnew) is in PYTHONPATH" + ) + + +def get_available_backends() -> dict: + """ + Check which backends are available + + Returns: + Dictionary with backend availability: + { + 'ipc': {'available': bool, 'version': str or None}, + 'swig': {'available': bool, 'version': str or None} + } + """ + results = {} + + # Check IPC (kicad-python uses 'kipy' module name) + try: + import kipy + + results["ipc"] = {"available": True, "version": getattr(kipy, "__version__", "unknown")} + except ImportError: + results["ipc"] = {"available": False, "version": None} + + # Check SWIG + try: + import pcbnew + + results["swig"] = {"available": True, "version": pcbnew.GetBuildVersion()} + except ImportError: + results["swig"] = {"available": False, "version": None} + + return results + + +if __name__ == "__main__": + # Quick diagnostic + import json + + print("KiCAD Backend Availability:") + print(json.dumps(get_available_backends(), indent=2)) + + print("\nAttempting to create backend...") + try: + backend = create_backend() + print(f"✓ Created backend: {type(backend).__name__}") + if backend.connect(): + print(f"✓ Connected to KiCAD: {backend.get_version()}") + else: + print("✗ Failed to connect to KiCAD") + except Exception as e: + print(f"✗ Error: {e}") diff --git a/python/kicad_api/ipc_backend.py b/python/kicad_api/ipc_backend.py index e69b94e..27821d0 100644 --- a/python/kicad_api/ipc_backend.py +++ b/python/kicad_api/ipc_backend.py @@ -1,1234 +1,1234 @@ -""" -IPC API Backend (KiCAD 9.0+) - -Uses the official kicad-python library for inter-process communication -with a running KiCAD instance. This enables REAL-TIME UI synchronization. - -Note: Requires KiCAD to be running with IPC server enabled: - Preferences > Plugins > Enable IPC API Server - -Key Benefits over SWIG: -- Changes appear instantly in KiCAD UI (no reload needed) -- Transaction support for undo/redo -- Stable API that won't break between versions -- Multi-language support -""" - -import logging -import os -import platform -from pathlib import Path -from typing import Any, Callable, Dict, List, Optional - -from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend - -logger = logging.getLogger(__name__) - -# Unit conversion constant: KiCAD IPC uses nanometers internally -MM_TO_NM = 1_000_000 -INCH_TO_NM = 25_400_000 - - -class IPCBackend(KiCADBackend): - """ - KiCAD IPC API backend for real-time UI synchronization. - - Communicates with KiCAD via Protocol Buffers over UNIX sockets. - Requires KiCAD 9.0+ to be running with IPC enabled. - - Changes made through this backend appear immediately in the KiCAD UI - without requiring manual reload. - """ - - def __init__(self) -> None: - self._kicad = None - self._connected = False - self._version: Optional[str] = None - self._on_change_callbacks: List[Callable] = [] - - def connect(self, socket_path: Optional[str] = None) -> bool: - """ - Connect to running KiCAD instance via IPC. - - Args: - socket_path: Optional socket path. If not provided, will try common locations. - Use format: ipc:///tmp/kicad/api.sock - - Returns: - True if connection successful - - Raises: - ConnectionError: If connection fails - """ - try: - # Import here to allow module to load even without kicad-python - from kipy import KiCad - - logger.info("Connecting to KiCAD via IPC...") - - # Try to connect with provided path or auto-detect - socket_paths_to_try = [] - if socket_path: - socket_paths_to_try.append(socket_path) - else: - # Common socket locations (Unix-like systems only) - # Windows uses named pipes, handled by auto-detect - if platform.system() != "Windows": - socket_paths_to_try.append("ipc:///tmp/kicad/api.sock") # Linux default - # XDG runtime directory (requires getuid, Unix only) - if hasattr(os, "getuid"): - socket_paths_to_try.append(f"ipc:///run/user/{os.getuid()}/kicad/api.sock") - - # Auto-detect for all platforms (Windows uses named pipes, Unix uses sockets) - socket_paths_to_try.append(None) - - last_error = None - for path in socket_paths_to_try: - try: - if path: - logger.debug(f"Trying socket path: {path}") - self._kicad = KiCad(socket_path=path) - else: - logger.debug("Trying auto-detection") - self._kicad = KiCad() - - # Verify connection with ping (ping returns None on success) - self._kicad.ping() - logger.info(f"Connected via socket: {path or 'auto-detected'}") - break - except Exception as e: - last_error = e - logger.debug(f"Failed to connect via {path}: {e}") - continue - else: - # None of the paths worked - raise ConnectionError(f"Could not connect to KiCAD IPC: {last_error}") - - # Get version info - self._version = self._get_kicad_version() - logger.info(f"Connected to KiCAD {self._version} via IPC") - self._connected = True - return True - - except ImportError as e: - logger.error("kicad-python library not found") - raise APINotAvailableError( - "IPC backend requires kicad-python. " "Install with: pip install kicad-python" - ) from e - except Exception as e: - logger.error(f"Failed to connect via IPC: {e}") - logger.info( - "Ensure KiCAD is running with IPC enabled: " - "Preferences > Plugins > Enable IPC API Server" - ) - raise ConnectionError(f"IPC connection failed: {e}") from e - - def _get_kicad_version(self) -> str: - """Get KiCAD version string.""" - try: - if self._kicad.check_version(): - return self._kicad.get_api_version() - return "9.0+ (version mismatch)" - except Exception: - return "unknown" - - def disconnect(self) -> None: - """Disconnect from KiCAD.""" - if self._kicad: - self._kicad = None - self._connected = False - logger.info("Disconnected from KiCAD IPC") - - def is_connected(self) -> bool: - """Check if connected to KiCAD.""" - if not self._connected or not self._kicad: - return False - try: - # ping() returns None on success, raises on failure - self._kicad.ping() - return True - except Exception: - self._connected = False - return False - - def get_version(self) -> str: - """Get KiCAD version.""" - return self._version or "unknown" - - def register_change_callback(self, callback: Callable) -> None: - """Register a callback to be called when changes are made.""" - self._on_change_callbacks.append(callback) - - def _notify_change(self, change_type: str, details: Dict[str, Any]) -> None: - """Notify registered callbacks of a change.""" - for callback in self._on_change_callbacks: - try: - callback(change_type, details) - except Exception as e: - logger.warning(f"Change callback error: {e}") - - # Project Operations - def create_project(self, path: Path, name: str) -> Dict[str, Any]: - """ - Create a new KiCAD project. - - Note: The IPC API doesn't directly create projects. - Projects must be created through the UI or file system. - """ - if not self.is_connected(): - raise ConnectionError("Not connected to KiCAD") - - # IPC API doesn't have project creation - use file-based approach - logger.warning("Project creation via IPC not fully supported - using hybrid approach") - - # For now, we'll return info about what needs to happen - return { - "success": False, - "message": "Direct project creation not supported via IPC", - "suggestion": "Open KiCAD and create a new project, or use SWIG backend", - } - - def open_project(self, path: Path) -> Dict[str, Any]: - """Open existing project via IPC.""" - if not self.is_connected(): - raise ConnectionError("Not connected to KiCAD") - - try: - # Check for open documents - documents = self._kicad.get_open_documents() - - # Look for matching project - path_str = str(path) - for doc in documents: - if path_str in str(doc): - return { - "success": True, - "message": f"Project already open: {path}", - "path": str(path), - } - - return { - "success": False, - "message": "Project not currently open in KiCAD", - "suggestion": "Open the project in KiCAD first, then connect via IPC", - } - - except Exception as e: - logger.error(f"Failed to check project: {e}") - return {"success": False, "message": "Failed to check project", "errorDetails": str(e)} - - def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]: - """Save current project via IPC.""" - if not self.is_connected(): - raise ConnectionError("Not connected to KiCAD") - - try: - board = self._kicad.get_board() - if path: - board.save_as(str(path)) - else: - board.save() - - self._notify_change("save", {"path": str(path) if path else "current"}) - - return {"success": True, "message": "Project saved successfully"} - except Exception as e: - logger.error(f"Failed to save project: {e}") - return {"success": False, "message": "Failed to save project", "errorDetails": str(e)} - - def close_project(self) -> None: - """Close current project (not supported via IPC).""" - logger.warning("Closing projects via IPC is not supported") - - # Board Operations - def get_board(self) -> BoardAPI: - """Get board API for real-time manipulation.""" - if not self.is_connected(): - raise ConnectionError("Not connected to KiCAD") - - return IPCBoardAPI(self._kicad, self._notify_change) - - -class IPCBoardAPI(BoardAPI): - """ - Board API implementation for IPC backend. - - All changes made through this API appear immediately in the KiCAD UI. - Uses transactions for proper undo/redo support. - """ - - def __init__(self, kicad_instance: Any, notify_callback: Callable) -> None: - self._kicad = kicad_instance - self._board = None - self._notify = notify_callback - self._current_commit = None - - def _get_board(self) -> Any: - """Get board instance, connecting if needed.""" - if self._board is None: - try: - self._board = self._kicad.get_board() - except Exception as e: - logger.error(f"Failed to get board: {e}") - raise ConnectionError(f"No board open in KiCAD: {e}") - return self._board - - def begin_transaction(self, description: str = "MCP Operation") -> None: - """Begin a transaction for grouping operations into a single undo step.""" - board = self._get_board() - self._current_commit = board.begin_commit() - logger.debug(f"Started transaction: {description}") - - def commit_transaction(self, description: str = "MCP Operation") -> None: - """Commit the current transaction.""" - if self._current_commit: - board = self._get_board() - board.push_commit(self._current_commit, description) - self._current_commit = None - logger.debug(f"Committed transaction: {description}") - - def rollback_transaction(self) -> None: - """Roll back the current transaction.""" - if self._current_commit: - board = self._get_board() - board.drop_commit(self._current_commit) - self._current_commit = None - logger.debug("Rolled back transaction") - - def save(self) -> bool: - """Save the board immediately.""" - try: - board = self._get_board() - board.save() - self._notify("save", {}) - return True - except Exception as e: - logger.error(f"Failed to save board: {e}") - return False - - def set_size(self, width: float, height: float, unit: str = "mm") -> bool: - """ - Set board size. - - Note: Board size in KiCAD is typically defined by the board outline, - not a direct size property. This method may need to create/modify - the board outline. - """ - try: - from kipy.board_types import BoardRectangle - from kipy.geometry import Vector2 - from kipy.proto.board.board_types_pb2 import BoardLayer - from kipy.util.units import from_mm - - board = self._get_board() - - # Convert to nm - if unit == "mm": - w = from_mm(width) - h = from_mm(height) - else: - w = int(width * INCH_TO_NM) - h = int(height * INCH_TO_NM) - - # Create board outline rectangle on Edge.Cuts layer - rect = BoardRectangle() - rect.start = Vector2.from_xy(0, 0) - rect.end = Vector2.from_xy(w, h) - rect.layer = BoardLayer.BL_Edge_Cuts - rect.width = from_mm(0.1) # Standard edge cut width - - # Begin transaction for undo support - commit = board.begin_commit() - board.create_items(rect) - board.push_commit(commit, f"Set board size to {width}x{height} {unit}") - - self._notify("board_size", {"width": width, "height": height, "unit": unit}) - - return True - - except Exception as e: - logger.error(f"Failed to set board size: {e}") - return False - - def get_size(self) -> Dict[str, Any]: - """Get current board size from bounding box.""" - try: - board = self._get_board() - - # Get shapes on Edge.Cuts layer to determine board size - shapes = board.get_shapes() - - if not shapes: - return {"width": 0, "height": 0, "unit": "mm"} - - # Find bounding box of edge cuts - from kipy.util.units import to_mm - - min_x = min_y = float("inf") - max_x = max_y = float("-inf") - - for shape in shapes: - # Check if on Edge.Cuts layer - bbox = board.get_item_bounding_box(shape) - if bbox: - min_x = min(min_x, bbox.min.x) - min_y = min(min_y, bbox.min.y) - max_x = max(max_x, bbox.max.x) - max_y = max(max_y, bbox.max.y) - - if min_x == float("inf"): - return {"width": 0, "height": 0, "unit": "mm"} - - return {"width": to_mm(max_x - min_x), "height": to_mm(max_y - min_y), "unit": "mm"} - - except Exception as e: - logger.error(f"Failed to get board size: {e}") - return {"width": 0, "height": 0, "unit": "mm", "error": str(e)} - - def add_layer(self, layer_name: str, layer_type: str) -> bool: - """Add layer to the board (layers are typically predefined in KiCAD).""" - logger.warning("Layer management via IPC is limited - layers are predefined") - return False - - def get_enabled_layers(self) -> List[str]: - """Get list of enabled layers.""" - try: - board = self._get_board() - layers = board.get_enabled_layers() - return [str(layer) for layer in layers] - except Exception as e: - logger.error(f"Failed to get enabled layers: {e}") - return [] - - def list_components(self) -> List[Dict[str, Any]]: - """List all components (footprints) on the board.""" - try: - from kipy.util.units import to_mm - - board = self._get_board() - footprints = board.get_footprints() - - components = [] - for fp in footprints: - try: - pos = fp.position - components.append( - { - "reference": ( - fp.reference_field.text.value if fp.reference_field else "" - ), - "value": fp.value_field.text.value if fp.value_field else "", - "footprint": str(fp.definition.library_link) if fp.definition else "", - "position": { - "x": to_mm(pos.x) if pos else 0, - "y": to_mm(pos.y) if pos else 0, - "unit": "mm", - }, - "rotation": fp.orientation.degrees if fp.orientation else 0, - "layer": str(fp.layer) if hasattr(fp, "layer") else "F.Cu", - "id": str(fp.id) if hasattr(fp, "id") else "", - } - ) - except Exception as e: - logger.warning(f"Error processing footprint: {e}") - continue - - return components - - except Exception as e: - logger.error(f"Failed to list components: {e}") - return [] - - def place_component( - self, - reference: str, - footprint: str, - x: float, - y: float, - rotation: float = 0, - layer: str = "F.Cu", - value: str = "", - ) -> bool: - """ - Place a component on the board. - - The component appears immediately in the KiCAD UI. - - This method uses a hybrid approach: - 1. Load the footprint definition from the library using pcbnew (SWIG) - 2. Place it on the board via IPC for real-time UI updates - - Args: - reference: Component reference designator (e.g., "R1", "U1") - footprint: Footprint path in format "Library:FootprintName" or just "FootprintName" - x: X position in mm - y: Y position in mm - rotation: Rotation angle in degrees - layer: Layer name ("F.Cu" for top, "B.Cu" for bottom) - value: Component value (optional) - """ - try: - # First, try to load the footprint from library using pcbnew SWIG - loaded_fp = self._load_footprint_from_library(footprint) - - if loaded_fp: - # We have the footprint from the library - place it via SWIG - # then sync to IPC for UI update - return self._place_loaded_footprint( - loaded_fp, reference, x, y, rotation, layer, value - ) - else: - # Fallback: Create a basic placeholder footprint via IPC - logger.warning( - f"Could not load footprint '{footprint}' from library, creating placeholder" - ) - return self._place_placeholder_footprint( - reference, footprint, x, y, rotation, layer, value - ) - - except Exception as e: - logger.error(f"Failed to place component: {e}") - return False - - def _load_footprint_from_library(self, footprint_path: str) -> Any: - """ - Load a footprint from the library using pcbnew SWIG API. - - Args: - footprint_path: Either "Library:FootprintName" or just "FootprintName" - - Returns: - pcbnew.FOOTPRINT object or None if not found - """ - try: - import pcbnew - - # Parse library and footprint name - if ":" in footprint_path: - lib_name, fp_name = footprint_path.split(":", 1) - else: - # Try to find the footprint in all libraries - lib_name = None - fp_name = footprint_path - - # Get the footprint library table - fp_lib_table = pcbnew.GetGlobalFootprintLib() - - if lib_name: - # Load from specific library - try: - loaded_fp = pcbnew.FootprintLoad(fp_lib_table, lib_name, fp_name) - if loaded_fp: - logger.info(f"Loaded footprint '{fp_name}' from library '{lib_name}'") - return loaded_fp - except Exception as e: - logger.warning(f"Could not load from {lib_name}: {e}") - else: - # Search all libraries for the footprint - lib_names = fp_lib_table.GetLogicalLibs() - for lib in lib_names: - try: - loaded_fp = pcbnew.FootprintLoad(fp_lib_table, lib, fp_name) - if loaded_fp: - logger.info(f"Found footprint '{fp_name}' in library '{lib}'") - return loaded_fp - except: - continue - - logger.warning(f"Footprint '{footprint_path}' not found in any library") - return None - - except ImportError: - logger.warning("pcbnew not available - cannot load footprints from library") - return None - except Exception as e: - logger.error(f"Error loading footprint from library: {e}") - return None - - def _place_loaded_footprint( - self, - loaded_fp: Any, - reference: str, - x: float, - y: float, - rotation: float, - layer: str, - value: str, - ) -> bool: - """ - Place a loaded pcbnew footprint onto the board. - - Uses SWIG to add the footprint, then notifies for IPC sync. - """ - try: - import pcbnew - - # Get the board file path from IPC to load via pcbnew - board = self._get_board() - - # Get the pcbnew board instance - # We need to get the actual board file path - project = board.get_project() - board_path = None - - # Try to get the board path from kipy - try: - docs = self._kicad.get_open_documents() - for doc in docs: - if hasattr(doc, "path") and str(doc.path).endswith(".kicad_pcb"): - board_path = str(doc.path) - break - except Exception as e: - logger.debug(f"Could not get board path from IPC: {e}") - - if board_path and os.path.exists(board_path): - # Load board via pcbnew - pcb_board = pcbnew.LoadBoard(board_path) - else: - # Try to get from pcbnew directly - pcb_board = pcbnew.GetBoard() - - if not pcb_board: - logger.error("Could not get pcbnew board instance") - return self._place_placeholder_footprint( - reference, "", x, y, rotation, layer, value - ) - - # Set footprint position and properties - scale = MM_TO_NM - loaded_fp.SetPosition(pcbnew.VECTOR2I(int(x * scale), int(y * scale))) - loaded_fp.SetOrientationDegrees(rotation) - - # Set reference - loaded_fp.SetReference(reference) - - # Set value if provided - if value: - loaded_fp.SetValue(value) - - # Set layer (flip if bottom) - if layer == "B.Cu": - if not loaded_fp.IsFlipped(): - loaded_fp.Flip(loaded_fp.GetPosition(), False) - - # Add to board - pcb_board.Add(loaded_fp) - - # Save the board so IPC can see the changes - pcbnew.SaveBoard(board_path, pcb_board) - - # Refresh IPC view - try: - board.revert() # Reload from disk to sync IPC - except Exception as e: - logger.debug(f"Could not refresh IPC board: {e}") - - self._notify( - "component_placed", - { - "reference": reference, - "footprint": loaded_fp.GetFPIDAsString(), - "position": {"x": x, "y": y}, - "rotation": rotation, - "layer": layer, - "loaded_from_library": True, - }, - ) - - logger.info( - f"Placed component {reference} ({loaded_fp.GetFPIDAsString()}) at ({x}, {y}) mm" - ) - return True - - except Exception as e: - logger.error(f"Error placing loaded footprint: {e}") - # Fall back to placeholder - return self._place_placeholder_footprint(reference, "", x, y, rotation, layer, value) - - def _place_placeholder_footprint( - self, - reference: str, - footprint: str, - x: float, - y: float, - rotation: float, - layer: str, - value: str, - ) -> bool: - """ - Place a placeholder footprint when library loading fails. - - Creates a basic footprint via IPC with just reference/value fields. - """ - try: - from kipy.board_types import Footprint - from kipy.geometry import Angle, Vector2 - from kipy.proto.board.board_types_pb2 import BoardLayer - from kipy.util.units import from_mm - - board = self._get_board() - - # Create footprint - fp = Footprint() - fp.position = Vector2.from_xy(from_mm(x), from_mm(y)) - fp.orientation = Angle.from_degrees(rotation) - - # Set layer - if layer == "B.Cu": - fp.layer = BoardLayer.BL_B_Cu - else: - fp.layer = BoardLayer.BL_F_Cu - - # Set reference and value - if fp.reference_field: - fp.reference_field.text.value = reference - if fp.value_field: - fp.value_field.text.value = value if value else footprint - - # Begin transaction - commit = board.begin_commit() - board.create_items(fp) - board.push_commit(commit, f"Placed component {reference}") - - self._notify( - "component_placed", - { - "reference": reference, - "footprint": footprint, - "position": {"x": x, "y": y}, - "rotation": rotation, - "layer": layer, - "loaded_from_library": False, - "is_placeholder": True, - }, - ) - - logger.info(f"Placed placeholder component {reference} at ({x}, {y}) mm") - return True - - except Exception as e: - logger.error(f"Failed to place placeholder component: {e}") - return False - - def move_component( - self, reference: str, x: float, y: float, rotation: Optional[float] = None - ) -> bool: - """Move a component to a new position (updates UI immediately).""" - try: - from kipy.geometry import Angle, Vector2 - from kipy.util.units import from_mm - - board = self._get_board() - footprints = board.get_footprints() - - # Find the footprint by reference - target_fp = None - for fp in footprints: - if fp.reference_field and fp.reference_field.text.value == reference: - target_fp = fp - break - - if not target_fp: - logger.error(f"Component not found: {reference}") - return False - - # Update position - target_fp.position = Vector2.from_xy(from_mm(x), from_mm(y)) - - if rotation is not None: - target_fp.orientation = Angle.from_degrees(rotation) - - # Apply changes - commit = board.begin_commit() - board.update_items([target_fp]) - board.push_commit(commit, f"Moved component {reference}") - - self._notify( - "component_moved", - {"reference": reference, "position": {"x": x, "y": y}, "rotation": rotation}, - ) - - return True - - except Exception as e: - logger.error(f"Failed to move component: {e}") - return False - - def delete_component(self, reference: str) -> bool: - """Delete a component from the board.""" - try: - board = self._get_board() - footprints = board.get_footprints() - - # Find the footprint by reference - target_fp = None - for fp in footprints: - if fp.reference_field and fp.reference_field.text.value == reference: - target_fp = fp - break - - if not target_fp: - logger.error(f"Component not found: {reference}") - return False - - # Remove component - commit = board.begin_commit() - board.remove_items([target_fp]) - board.push_commit(commit, f"Deleted component {reference}") - - self._notify("component_deleted", {"reference": reference}) - - return True - - except Exception as e: - logger.error(f"Failed to delete component: {e}") - return False - - def add_track( - self, - start_x: float, - start_y: float, - end_x: float, - end_y: float, - width: float = 0.25, - layer: str = "F.Cu", - net_name: Optional[str] = None, - ) -> bool: - """ - Add a track (trace) to the board. - - The track appears immediately in the KiCAD UI. - """ - try: - from kipy.board_types import Track - from kipy.geometry import Vector2 - from kipy.proto.board.board_types_pb2 import BoardLayer - from kipy.util.units import from_mm - - board = self._get_board() - - # Create track - track = Track() - track.start = Vector2.from_xy(from_mm(start_x), from_mm(start_y)) - track.end = Vector2.from_xy(from_mm(end_x), from_mm(end_y)) - track.width = from_mm(width) - - # Set layer - layer_map = { - "F.Cu": BoardLayer.BL_F_Cu, - "B.Cu": BoardLayer.BL_B_Cu, - "In1.Cu": BoardLayer.BL_In1_Cu, - "In2.Cu": BoardLayer.BL_In2_Cu, - } - track.layer = layer_map.get(layer, BoardLayer.BL_F_Cu) - - # Set net if specified - if net_name: - nets = board.get_nets() - for net in nets: - if net.name == net_name: - track.net = net - break - - # Add track with transaction - commit = board.begin_commit() - board.create_items(track) - board.push_commit(commit, "Added track") - - self._notify( - "track_added", - { - "start": {"x": start_x, "y": start_y}, - "end": {"x": end_x, "y": end_y}, - "width": width, - "layer": layer, - "net": net_name, - }, - ) - - logger.info(f"Added track from ({start_x}, {start_y}) to ({end_x}, {end_y}) mm") - return True - - except Exception as e: - logger.error(f"Failed to add track: {e}") - return False - - def add_via( - self, - x: float, - y: float, - diameter: float = 0.8, - drill: float = 0.4, - net_name: Optional[str] = None, - via_type: str = "through", - ) -> bool: - """ - Add a via to the board. - - The via appears immediately in the KiCAD UI. - """ - try: - from kipy.board_types import Via - from kipy.geometry import Vector2 - from kipy.proto.board.board_types_pb2 import ViaType - from kipy.util.units import from_mm - - board = self._get_board() - - # Create via - via = Via() - via.position = Vector2.from_xy(from_mm(x), from_mm(y)) - via.diameter = from_mm(diameter) - via.drill_diameter = from_mm(drill) - - # Set via type (enum values: VT_THROUGH=1, VT_BLIND_BURIED=2, VT_MICRO=3) - type_map = { - "through": ViaType.VT_THROUGH, - "blind": ViaType.VT_BLIND_BURIED, - "micro": ViaType.VT_MICRO, - } - via.type = type_map.get(via_type, ViaType.VT_THROUGH) - - # Set net if specified - if net_name: - nets = board.get_nets() - for net in nets: - if net.name == net_name: - via.net = net - break - - # Add via with transaction - commit = board.begin_commit() - board.create_items(via) - board.push_commit(commit, "Added via") - - self._notify( - "via_added", - { - "position": {"x": x, "y": y}, - "diameter": diameter, - "drill": drill, - "net": net_name, - "type": via_type, - }, - ) - - logger.info(f"Added via at ({x}, {y}) mm") - return True - - except Exception as e: - logger.error(f"Failed to add via: {e}") - return False - - def add_text( - self, - text: str, - x: float, - y: float, - layer: str = "F.SilkS", - size: float = 1.0, - rotation: float = 0, - ) -> bool: - """Add text to the board.""" - try: - from kipy.board_types import BoardText - from kipy.geometry import Angle, Vector2 - from kipy.proto.board.board_types_pb2 import BoardLayer - from kipy.util.units import from_mm - - board = self._get_board() - - # Create text - board_text = BoardText() - board_text.value = text - board_text.position = Vector2.from_xy(from_mm(x), from_mm(y)) - board_text.angle = Angle.from_degrees(rotation) - - # Set layer - layer_map = { - "F.SilkS": BoardLayer.BL_F_SilkS, - "B.SilkS": BoardLayer.BL_B_SilkS, - "F.Cu": BoardLayer.BL_F_Cu, - "B.Cu": BoardLayer.BL_B_Cu, - } - board_text.layer = layer_map.get(layer, BoardLayer.BL_F_SilkS) - - # Add text with transaction - commit = board.begin_commit() - board.create_items(board_text) - board.push_commit(commit, f"Added text: {text}") - - self._notify("text_added", {"text": text, "position": {"x": x, "y": y}, "layer": layer}) - - return True - - except Exception as e: - logger.error(f"Failed to add text: {e}") - return False - - def get_tracks(self) -> List[Dict[str, Any]]: - """Get all tracks on the board.""" - try: - from kipy.util.units import to_mm - - board = self._get_board() - tracks = board.get_tracks() - - result = [] - for track in tracks: - try: - result.append( - { - "start": {"x": to_mm(track.start.x), "y": to_mm(track.start.y)}, - "end": {"x": to_mm(track.end.x), "y": to_mm(track.end.y)}, - "width": to_mm(track.width), - "layer": str(track.layer), - "net": track.net.name if track.net else "", - "id": str(track.id) if hasattr(track, "id") else "", - } - ) - except Exception as e: - logger.warning(f"Error processing track: {e}") - continue - - return result - - except Exception as e: - logger.error(f"Failed to get tracks: {e}") - return [] - - def get_vias(self) -> List[Dict[str, Any]]: - """Get all vias on the board.""" - try: - from kipy.util.units import to_mm - - board = self._get_board() - vias = board.get_vias() - - result = [] - for via in vias: - try: - result.append( - { - "position": {"x": to_mm(via.position.x), "y": to_mm(via.position.y)}, - "diameter": to_mm(via.diameter), - "drill": to_mm(via.drill_diameter), - "net": via.net.name if via.net else "", - "type": str(via.type), - "id": str(via.id) if hasattr(via, "id") else "", - } - ) - except Exception as e: - logger.warning(f"Error processing via: {e}") - continue - - return result - - except Exception as e: - logger.error(f"Failed to get vias: {e}") - return [] - - def get_nets(self) -> List[Dict[str, Any]]: - """Get all nets on the board.""" - try: - board = self._get_board() - nets = board.get_nets() - - result = [] - for net in nets: - try: - result.append( - {"name": net.name, "code": net.code if hasattr(net, "code") else 0} - ) - except Exception as e: - logger.warning(f"Error processing net: {e}") - continue - - return result - - except Exception as e: - logger.error(f"Failed to get nets: {e}") - return [] - - def add_zone( - self, - points: List[Dict[str, float]], - layer: str = "F.Cu", - net_name: Optional[str] = None, - clearance: float = 0.5, - min_thickness: float = 0.25, - priority: int = 0, - fill_mode: str = "solid", - name: str = "", - ) -> bool: - """ - Add a copper pour zone to the board. - - The zone appears immediately in the KiCAD UI. - - Args: - points: List of points defining the zone outline, e.g. [{"x": 0, "y": 0}, ...] - layer: Layer name (F.Cu, B.Cu, etc.) - net_name: Net to connect the zone to (e.g., "GND") - clearance: Clearance from other copper in mm - min_thickness: Minimum copper thickness in mm - priority: Zone priority (higher = fills first) - fill_mode: "solid" or "hatched" - name: Optional zone name - """ - try: - from kipy.board_types import Zone, ZoneFillMode, ZoneType - from kipy.geometry import PolyLine, PolyLineNode, Vector2 - from kipy.proto.board.board_types_pb2 import BoardLayer - from kipy.util.units import from_mm - - board = self._get_board() - - if len(points) < 3: - logger.error("Zone requires at least 3 points") - return False - - # Create zone - zone = Zone() - zone.type = ZoneType.ZT_COPPER - - # Set layer - layer_map = { - "F.Cu": BoardLayer.BL_F_Cu, - "B.Cu": BoardLayer.BL_B_Cu, - "In1.Cu": BoardLayer.BL_In1_Cu, - "In2.Cu": BoardLayer.BL_In2_Cu, - "In3.Cu": BoardLayer.BL_In3_Cu, - "In4.Cu": BoardLayer.BL_In4_Cu, - } - zone.layers = [layer_map.get(layer, BoardLayer.BL_F_Cu)] - - # Set net if specified - if net_name: - nets = board.get_nets() - for net in nets: - if net.name == net_name: - zone.net = net - break - - # Set zone properties - zone.clearance = from_mm(clearance) - zone.min_thickness = from_mm(min_thickness) - zone.priority = priority - - if name: - zone.name = name - - # Set fill mode - if fill_mode == "hatched": - zone.fill_mode = ZoneFillMode.ZFM_HATCHED - else: - zone.fill_mode = ZoneFillMode.ZFM_SOLID - - # Create outline polyline - outline = PolyLine() - outline.closed = True - - for point in points: - x = point.get("x", 0) - y = point.get("y", 0) - node = PolyLineNode.from_xy(from_mm(x), from_mm(y)) - outline.append(node) - - # Set the outline on the zone - # Note: Zone outline is set via the proto directly since kipy - # doesn't expose a direct setter for creating new zones - zone._proto.outline.polygons.add() - zone._proto.outline.polygons[0].outline.CopyFrom(outline._proto) - - # Add zone with transaction - commit = board.begin_commit() - board.create_items(zone) - board.push_commit(commit, f"Added copper zone on {layer}") - - self._notify( - "zone_added", - {"layer": layer, "net": net_name, "points": len(points), "priority": priority}, - ) - - logger.info(f"Added zone on {layer} with {len(points)} points") - return True - - except Exception as e: - logger.error(f"Failed to add zone: {e}") - return False - - def get_zones(self) -> List[Dict[str, Any]]: - """Get all zones on the board.""" - try: - from kipy.util.units import to_mm - - board = self._get_board() - zones = board.get_zones() - - result = [] - for zone in zones: - try: - result.append( - { - "name": zone.name if hasattr(zone, "name") else "", - "net": zone.net.name if zone.net else "", - "priority": zone.priority if hasattr(zone, "priority") else 0, - "layers": ( - [str(l) for l in zone.layers] if hasattr(zone, "layers") else [] - ), - "filled": zone.filled if hasattr(zone, "filled") else False, - "id": str(zone.id) if hasattr(zone, "id") else "", - } - ) - except Exception as e: - logger.warning(f"Error processing zone: {e}") - continue - - return result - - except Exception as e: - logger.error(f"Failed to get zones: {e}") - return [] - - def refill_zones(self) -> bool: - """Refill all copper pour zones.""" - try: - board = self._get_board() - board.refill_zones() - self._notify("zones_refilled", {}) - return True - except Exception as e: - logger.error(f"Failed to refill zones: {e}") - return False - - def get_selection(self) -> List[Dict[str, Any]]: - """Get currently selected items in the KiCAD UI.""" - try: - board = self._get_board() - selection = board.get_selection() - - result = [] - for item in selection: - result.append( - {"type": type(item).__name__, "id": str(item.id) if hasattr(item, "id") else ""} - ) - - return result - except Exception as e: - logger.error(f"Failed to get selection: {e}") - return [] - - def clear_selection(self) -> bool: - """Clear the current selection in KiCAD UI.""" - try: - board = self._get_board() - board.clear_selection() - return True - except Exception as e: - logger.error(f"Failed to clear selection: {e}") - return False - - -# Export for factory -__all__ = ["IPCBackend", "IPCBoardAPI"] +""" +IPC API Backend (KiCAD 9.0+) + +Uses the official kicad-python library for inter-process communication +with a running KiCAD instance. This enables REAL-TIME UI synchronization. + +Note: Requires KiCAD to be running with IPC server enabled: + Preferences > Plugins > Enable IPC API Server + +Key Benefits over SWIG: +- Changes appear instantly in KiCAD UI (no reload needed) +- Transaction support for undo/redo +- Stable API that won't break between versions +- Multi-language support +""" + +import logging +import os +import platform +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend + +logger = logging.getLogger(__name__) + +# Unit conversion constant: KiCAD IPC uses nanometers internally +MM_TO_NM = 1_000_000 +INCH_TO_NM = 25_400_000 + + +class IPCBackend(KiCADBackend): + """ + KiCAD IPC API backend for real-time UI synchronization. + + Communicates with KiCAD via Protocol Buffers over UNIX sockets. + Requires KiCAD 9.0+ to be running with IPC enabled. + + Changes made through this backend appear immediately in the KiCAD UI + without requiring manual reload. + """ + + def __init__(self) -> None: + self._kicad = None + self._connected = False + self._version: Optional[str] = None + self._on_change_callbacks: List[Callable] = [] + + def connect(self, socket_path: Optional[str] = None) -> bool: + """ + Connect to running KiCAD instance via IPC. + + Args: + socket_path: Optional socket path. If not provided, will try common locations. + Use format: ipc:///tmp/kicad/api.sock + + Returns: + True if connection successful + + Raises: + ConnectionError: If connection fails + """ + try: + # Import here to allow module to load even without kicad-python + from kipy import KiCad + + logger.info("Connecting to KiCAD via IPC...") + + # Try to connect with provided path or auto-detect + socket_paths_to_try = [] + if socket_path: + socket_paths_to_try.append(socket_path) + else: + # Common socket locations (Unix-like systems only) + # Windows uses named pipes, handled by auto-detect + if platform.system() != "Windows": + socket_paths_to_try.append("ipc:///tmp/kicad/api.sock") # Linux default + # XDG runtime directory (requires getuid, Unix only) + if hasattr(os, "getuid"): + socket_paths_to_try.append(f"ipc:///run/user/{os.getuid()}/kicad/api.sock") + + # Auto-detect for all platforms (Windows uses named pipes, Unix uses sockets) + socket_paths_to_try.append(None) + + last_error = None + for path in socket_paths_to_try: + try: + if path: + logger.debug(f"Trying socket path: {path}") + self._kicad = KiCad(socket_path=path) + else: + logger.debug("Trying auto-detection") + self._kicad = KiCad() + + # Verify connection with ping (ping returns None on success) + self._kicad.ping() + logger.info(f"Connected via socket: {path or 'auto-detected'}") + break + except Exception as e: + last_error = e + logger.debug(f"Failed to connect via {path}: {e}") + continue + else: + # None of the paths worked + raise ConnectionError(f"Could not connect to KiCAD IPC: {last_error}") + + # Get version info + self._version = self._get_kicad_version() + logger.info(f"Connected to KiCAD {self._version} via IPC") + self._connected = True + return True + + except ImportError as e: + logger.error("kicad-python library not found") + raise APINotAvailableError( + "IPC backend requires kicad-python. " "Install with: pip install kicad-python" + ) from e + except Exception as e: + logger.error(f"Failed to connect via IPC: {e}") + logger.info( + "Ensure KiCAD is running with IPC enabled: " + "Preferences > Plugins > Enable IPC API Server" + ) + raise ConnectionError(f"IPC connection failed: {e}") from e + + def _get_kicad_version(self) -> str: + """Get KiCAD version string.""" + try: + if self._kicad.check_version(): + return self._kicad.get_api_version() + return "9.0+ (version mismatch)" + except Exception: + return "unknown" + + def disconnect(self) -> None: + """Disconnect from KiCAD.""" + if self._kicad: + self._kicad = None + self._connected = False + logger.info("Disconnected from KiCAD IPC") + + def is_connected(self) -> bool: + """Check if connected to KiCAD.""" + if not self._connected or not self._kicad: + return False + try: + # ping() returns None on success, raises on failure + self._kicad.ping() + return True + except Exception: + self._connected = False + return False + + def get_version(self) -> str: + """Get KiCAD version.""" + return self._version or "unknown" + + def register_change_callback(self, callback: Callable) -> None: + """Register a callback to be called when changes are made.""" + self._on_change_callbacks.append(callback) + + def _notify_change(self, change_type: str, details: Dict[str, Any]) -> None: + """Notify registered callbacks of a change.""" + for callback in self._on_change_callbacks: + try: + callback(change_type, details) + except Exception as e: + logger.warning(f"Change callback error: {e}") + + # Project Operations + def create_project(self, path: Path, name: str) -> Dict[str, Any]: + """ + Create a new KiCAD project. + + Note: The IPC API doesn't directly create projects. + Projects must be created through the UI or file system. + """ + if not self.is_connected(): + raise ConnectionError("Not connected to KiCAD") + + # IPC API doesn't have project creation - use file-based approach + logger.warning("Project creation via IPC not fully supported - using hybrid approach") + + # For now, we'll return info about what needs to happen + return { + "success": False, + "message": "Direct project creation not supported via IPC", + "suggestion": "Open KiCAD and create a new project, or use SWIG backend", + } + + def open_project(self, path: Path) -> Dict[str, Any]: + """Open existing project via IPC.""" + if not self.is_connected(): + raise ConnectionError("Not connected to KiCAD") + + try: + # Check for open documents + documents = self._kicad.get_open_documents() + + # Look for matching project + path_str = str(path) + for doc in documents: + if path_str in str(doc): + return { + "success": True, + "message": f"Project already open: {path}", + "path": str(path), + } + + return { + "success": False, + "message": "Project not currently open in KiCAD", + "suggestion": "Open the project in KiCAD first, then connect via IPC", + } + + except Exception as e: + logger.error(f"Failed to check project: {e}") + return {"success": False, "message": "Failed to check project", "errorDetails": str(e)} + + def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]: + """Save current project via IPC.""" + if not self.is_connected(): + raise ConnectionError("Not connected to KiCAD") + + try: + board = self._kicad.get_board() + if path: + board.save_as(str(path)) + else: + board.save() + + self._notify_change("save", {"path": str(path) if path else "current"}) + + return {"success": True, "message": "Project saved successfully"} + except Exception as e: + logger.error(f"Failed to save project: {e}") + return {"success": False, "message": "Failed to save project", "errorDetails": str(e)} + + def close_project(self) -> None: + """Close current project (not supported via IPC).""" + logger.warning("Closing projects via IPC is not supported") + + # Board Operations + def get_board(self) -> BoardAPI: + """Get board API for real-time manipulation.""" + if not self.is_connected(): + raise ConnectionError("Not connected to KiCAD") + + return IPCBoardAPI(self._kicad, self._notify_change) + + +class IPCBoardAPI(BoardAPI): + """ + Board API implementation for IPC backend. + + All changes made through this API appear immediately in the KiCAD UI. + Uses transactions for proper undo/redo support. + """ + + def __init__(self, kicad_instance: Any, notify_callback: Callable) -> None: + self._kicad = kicad_instance + self._board = None + self._notify = notify_callback + self._current_commit = None + + def _get_board(self) -> Any: + """Get board instance, connecting if needed.""" + if self._board is None: + try: + self._board = self._kicad.get_board() + except Exception as e: + logger.error(f"Failed to get board: {e}") + raise ConnectionError(f"No board open in KiCAD: {e}") + return self._board + + def begin_transaction(self, description: str = "MCP Operation") -> None: + """Begin a transaction for grouping operations into a single undo step.""" + board = self._get_board() + self._current_commit = board.begin_commit() + logger.debug(f"Started transaction: {description}") + + def commit_transaction(self, description: str = "MCP Operation") -> None: + """Commit the current transaction.""" + if self._current_commit: + board = self._get_board() + board.push_commit(self._current_commit, description) + self._current_commit = None + logger.debug(f"Committed transaction: {description}") + + def rollback_transaction(self) -> None: + """Roll back the current transaction.""" + if self._current_commit: + board = self._get_board() + board.drop_commit(self._current_commit) + self._current_commit = None + logger.debug("Rolled back transaction") + + def save(self) -> bool: + """Save the board immediately.""" + try: + board = self._get_board() + board.save() + self._notify("save", {}) + return True + except Exception as e: + logger.error(f"Failed to save board: {e}") + return False + + def set_size(self, width: float, height: float, unit: str = "mm") -> bool: + """ + Set board size. + + Note: Board size in KiCAD is typically defined by the board outline, + not a direct size property. This method may need to create/modify + the board outline. + """ + try: + from kipy.board_types import BoardRectangle + from kipy.geometry import Vector2 + from kipy.proto.board.board_types_pb2 import BoardLayer + from kipy.util.units import from_mm + + board = self._get_board() + + # Convert to nm + if unit == "mm": + w = from_mm(width) + h = from_mm(height) + else: + w = int(width * INCH_TO_NM) + h = int(height * INCH_TO_NM) + + # Create board outline rectangle on Edge.Cuts layer + rect = BoardRectangle() + rect.start = Vector2.from_xy(0, 0) + rect.end = Vector2.from_xy(w, h) + rect.layer = BoardLayer.BL_Edge_Cuts + rect.width = from_mm(0.1) # Standard edge cut width + + # Begin transaction for undo support + commit = board.begin_commit() + board.create_items(rect) + board.push_commit(commit, f"Set board size to {width}x{height} {unit}") + + self._notify("board_size", {"width": width, "height": height, "unit": unit}) + + return True + + except Exception as e: + logger.error(f"Failed to set board size: {e}") + return False + + def get_size(self) -> Dict[str, Any]: + """Get current board size from bounding box.""" + try: + board = self._get_board() + + # Get shapes on Edge.Cuts layer to determine board size + shapes = board.get_shapes() + + if not shapes: + return {"width": 0, "height": 0, "unit": "mm"} + + # Find bounding box of edge cuts + from kipy.util.units import to_mm + + min_x = min_y = float("inf") + max_x = max_y = float("-inf") + + for shape in shapes: + # Check if on Edge.Cuts layer + bbox = board.get_item_bounding_box(shape) + if bbox: + min_x = min(min_x, bbox.min.x) + min_y = min(min_y, bbox.min.y) + max_x = max(max_x, bbox.max.x) + max_y = max(max_y, bbox.max.y) + + if min_x == float("inf"): + return {"width": 0, "height": 0, "unit": "mm"} + + return {"width": to_mm(max_x - min_x), "height": to_mm(max_y - min_y), "unit": "mm"} + + except Exception as e: + logger.error(f"Failed to get board size: {e}") + return {"width": 0, "height": 0, "unit": "mm", "error": str(e)} + + def add_layer(self, layer_name: str, layer_type: str) -> bool: + """Add layer to the board (layers are typically predefined in KiCAD).""" + logger.warning("Layer management via IPC is limited - layers are predefined") + return False + + def get_enabled_layers(self) -> List[str]: + """Get list of enabled layers.""" + try: + board = self._get_board() + layers = board.get_enabled_layers() + return [str(layer) for layer in layers] + except Exception as e: + logger.error(f"Failed to get enabled layers: {e}") + return [] + + def list_components(self) -> List[Dict[str, Any]]: + """List all components (footprints) on the board.""" + try: + from kipy.util.units import to_mm + + board = self._get_board() + footprints = board.get_footprints() + + components = [] + for fp in footprints: + try: + pos = fp.position + components.append( + { + "reference": ( + fp.reference_field.text.value if fp.reference_field else "" + ), + "value": fp.value_field.text.value if fp.value_field else "", + "footprint": str(fp.definition.library_link) if fp.definition else "", + "position": { + "x": to_mm(pos.x) if pos else 0, + "y": to_mm(pos.y) if pos else 0, + "unit": "mm", + }, + "rotation": fp.orientation.degrees if fp.orientation else 0, + "layer": str(fp.layer) if hasattr(fp, "layer") else "F.Cu", + "id": str(fp.id) if hasattr(fp, "id") else "", + } + ) + except Exception as e: + logger.warning(f"Error processing footprint: {e}") + continue + + return components + + except Exception as e: + logger.error(f"Failed to list components: {e}") + return [] + + def place_component( + self, + reference: str, + footprint: str, + x: float, + y: float, + rotation: float = 0, + layer: str = "F.Cu", + value: str = "", + ) -> bool: + """ + Place a component on the board. + + The component appears immediately in the KiCAD UI. + + This method uses a hybrid approach: + 1. Load the footprint definition from the library using pcbnew (SWIG) + 2. Place it on the board via IPC for real-time UI updates + + Args: + reference: Component reference designator (e.g., "R1", "U1") + footprint: Footprint path in format "Library:FootprintName" or just "FootprintName" + x: X position in mm + y: Y position in mm + rotation: Rotation angle in degrees + layer: Layer name ("F.Cu" for top, "B.Cu" for bottom) + value: Component value (optional) + """ + try: + # First, try to load the footprint from library using pcbnew SWIG + loaded_fp = self._load_footprint_from_library(footprint) + + if loaded_fp: + # We have the footprint from the library - place it via SWIG + # then sync to IPC for UI update + return self._place_loaded_footprint( + loaded_fp, reference, x, y, rotation, layer, value + ) + else: + # Fallback: Create a basic placeholder footprint via IPC + logger.warning( + f"Could not load footprint '{footprint}' from library, creating placeholder" + ) + return self._place_placeholder_footprint( + reference, footprint, x, y, rotation, layer, value + ) + + except Exception as e: + logger.error(f"Failed to place component: {e}") + return False + + def _load_footprint_from_library(self, footprint_path: str) -> Any: + """ + Load a footprint from the library using pcbnew SWIG API. + + Args: + footprint_path: Either "Library:FootprintName" or just "FootprintName" + + Returns: + pcbnew.FOOTPRINT object or None if not found + """ + try: + import pcbnew + + # Parse library and footprint name + if ":" in footprint_path: + lib_name, fp_name = footprint_path.split(":", 1) + else: + # Try to find the footprint in all libraries + lib_name = None + fp_name = footprint_path + + # Get the footprint library table + fp_lib_table = pcbnew.GetGlobalFootprintLib() + + if lib_name: + # Load from specific library + try: + loaded_fp = pcbnew.FootprintLoad(fp_lib_table, lib_name, fp_name) + if loaded_fp: + logger.info(f"Loaded footprint '{fp_name}' from library '{lib_name}'") + return loaded_fp + except Exception as e: + logger.warning(f"Could not load from {lib_name}: {e}") + else: + # Search all libraries for the footprint + lib_names = fp_lib_table.GetLogicalLibs() + for lib in lib_names: + try: + loaded_fp = pcbnew.FootprintLoad(fp_lib_table, lib, fp_name) + if loaded_fp: + logger.info(f"Found footprint '{fp_name}' in library '{lib}'") + return loaded_fp + except: + continue + + logger.warning(f"Footprint '{footprint_path}' not found in any library") + return None + + except ImportError: + logger.warning("pcbnew not available - cannot load footprints from library") + return None + except Exception as e: + logger.error(f"Error loading footprint from library: {e}") + return None + + def _place_loaded_footprint( + self, + loaded_fp: Any, + reference: str, + x: float, + y: float, + rotation: float, + layer: str, + value: str, + ) -> bool: + """ + Place a loaded pcbnew footprint onto the board. + + Uses SWIG to add the footprint, then notifies for IPC sync. + """ + try: + import pcbnew + + # Get the board file path from IPC to load via pcbnew + board = self._get_board() + + # Get the pcbnew board instance + # We need to get the actual board file path + project = board.get_project() + board_path = None + + # Try to get the board path from kipy + try: + docs = self._kicad.get_open_documents() + for doc in docs: + if hasattr(doc, "path") and str(doc.path).endswith(".kicad_pcb"): + board_path = str(doc.path) + break + except Exception as e: + logger.debug(f"Could not get board path from IPC: {e}") + + if board_path and os.path.exists(board_path): + # Load board via pcbnew + pcb_board = pcbnew.LoadBoard(board_path) + else: + # Try to get from pcbnew directly + pcb_board = pcbnew.GetBoard() + + if not pcb_board: + logger.error("Could not get pcbnew board instance") + return self._place_placeholder_footprint( + reference, "", x, y, rotation, layer, value + ) + + # Set footprint position and properties + scale = MM_TO_NM + loaded_fp.SetPosition(pcbnew.VECTOR2I(int(x * scale), int(y * scale))) + loaded_fp.SetOrientationDegrees(rotation) + + # Set reference + loaded_fp.SetReference(reference) + + # Set value if provided + if value: + loaded_fp.SetValue(value) + + # Set layer (flip if bottom) + if layer == "B.Cu": + if not loaded_fp.IsFlipped(): + loaded_fp.Flip(loaded_fp.GetPosition(), False) + + # Add to board + pcb_board.Add(loaded_fp) + + # Save the board so IPC can see the changes + pcbnew.SaveBoard(board_path, pcb_board) + + # Refresh IPC view + try: + board.revert() # Reload from disk to sync IPC + except Exception as e: + logger.debug(f"Could not refresh IPC board: {e}") + + self._notify( + "component_placed", + { + "reference": reference, + "footprint": loaded_fp.GetFPIDAsString(), + "position": {"x": x, "y": y}, + "rotation": rotation, + "layer": layer, + "loaded_from_library": True, + }, + ) + + logger.info( + f"Placed component {reference} ({loaded_fp.GetFPIDAsString()}) at ({x}, {y}) mm" + ) + return True + + except Exception as e: + logger.error(f"Error placing loaded footprint: {e}") + # Fall back to placeholder + return self._place_placeholder_footprint(reference, "", x, y, rotation, layer, value) + + def _place_placeholder_footprint( + self, + reference: str, + footprint: str, + x: float, + y: float, + rotation: float, + layer: str, + value: str, + ) -> bool: + """ + Place a placeholder footprint when library loading fails. + + Creates a basic footprint via IPC with just reference/value fields. + """ + try: + from kipy.board_types import Footprint + from kipy.geometry import Angle, Vector2 + from kipy.proto.board.board_types_pb2 import BoardLayer + from kipy.util.units import from_mm + + board = self._get_board() + + # Create footprint + fp = Footprint() + fp.position = Vector2.from_xy(from_mm(x), from_mm(y)) + fp.orientation = Angle.from_degrees(rotation) + + # Set layer + if layer == "B.Cu": + fp.layer = BoardLayer.BL_B_Cu + else: + fp.layer = BoardLayer.BL_F_Cu + + # Set reference and value + if fp.reference_field: + fp.reference_field.text.value = reference + if fp.value_field: + fp.value_field.text.value = value if value else footprint + + # Begin transaction + commit = board.begin_commit() + board.create_items(fp) + board.push_commit(commit, f"Placed component {reference}") + + self._notify( + "component_placed", + { + "reference": reference, + "footprint": footprint, + "position": {"x": x, "y": y}, + "rotation": rotation, + "layer": layer, + "loaded_from_library": False, + "is_placeholder": True, + }, + ) + + logger.info(f"Placed placeholder component {reference} at ({x}, {y}) mm") + return True + + except Exception as e: + logger.error(f"Failed to place placeholder component: {e}") + return False + + def move_component( + self, reference: str, x: float, y: float, rotation: Optional[float] = None + ) -> bool: + """Move a component to a new position (updates UI immediately).""" + try: + from kipy.geometry import Angle, Vector2 + from kipy.util.units import from_mm + + board = self._get_board() + footprints = board.get_footprints() + + # Find the footprint by reference + target_fp = None + for fp in footprints: + if fp.reference_field and fp.reference_field.text.value == reference: + target_fp = fp + break + + if not target_fp: + logger.error(f"Component not found: {reference}") + return False + + # Update position + target_fp.position = Vector2.from_xy(from_mm(x), from_mm(y)) + + if rotation is not None: + target_fp.orientation = Angle.from_degrees(rotation) + + # Apply changes + commit = board.begin_commit() + board.update_items([target_fp]) + board.push_commit(commit, f"Moved component {reference}") + + self._notify( + "component_moved", + {"reference": reference, "position": {"x": x, "y": y}, "rotation": rotation}, + ) + + return True + + except Exception as e: + logger.error(f"Failed to move component: {e}") + return False + + def delete_component(self, reference: str) -> bool: + """Delete a component from the board.""" + try: + board = self._get_board() + footprints = board.get_footprints() + + # Find the footprint by reference + target_fp = None + for fp in footprints: + if fp.reference_field and fp.reference_field.text.value == reference: + target_fp = fp + break + + if not target_fp: + logger.error(f"Component not found: {reference}") + return False + + # Remove component + commit = board.begin_commit() + board.remove_items([target_fp]) + board.push_commit(commit, f"Deleted component {reference}") + + self._notify("component_deleted", {"reference": reference}) + + return True + + except Exception as e: + logger.error(f"Failed to delete component: {e}") + return False + + def add_track( + self, + start_x: float, + start_y: float, + end_x: float, + end_y: float, + width: float = 0.25, + layer: str = "F.Cu", + net_name: Optional[str] = None, + ) -> bool: + """ + Add a track (trace) to the board. + + The track appears immediately in the KiCAD UI. + """ + try: + from kipy.board_types import Track + from kipy.geometry import Vector2 + from kipy.proto.board.board_types_pb2 import BoardLayer + from kipy.util.units import from_mm + + board = self._get_board() + + # Create track + track = Track() + track.start = Vector2.from_xy(from_mm(start_x), from_mm(start_y)) + track.end = Vector2.from_xy(from_mm(end_x), from_mm(end_y)) + track.width = from_mm(width) + + # Set layer + layer_map = { + "F.Cu": BoardLayer.BL_F_Cu, + "B.Cu": BoardLayer.BL_B_Cu, + "In1.Cu": BoardLayer.BL_In1_Cu, + "In2.Cu": BoardLayer.BL_In2_Cu, + } + track.layer = layer_map.get(layer, BoardLayer.BL_F_Cu) + + # Set net if specified + if net_name: + nets = board.get_nets() + for net in nets: + if net.name == net_name: + track.net = net + break + + # Add track with transaction + commit = board.begin_commit() + board.create_items(track) + board.push_commit(commit, "Added track") + + self._notify( + "track_added", + { + "start": {"x": start_x, "y": start_y}, + "end": {"x": end_x, "y": end_y}, + "width": width, + "layer": layer, + "net": net_name, + }, + ) + + logger.info(f"Added track from ({start_x}, {start_y}) to ({end_x}, {end_y}) mm") + return True + + except Exception as e: + logger.error(f"Failed to add track: {e}") + return False + + def add_via( + self, + x: float, + y: float, + diameter: float = 0.8, + drill: float = 0.4, + net_name: Optional[str] = None, + via_type: str = "through", + ) -> bool: + """ + Add a via to the board. + + The via appears immediately in the KiCAD UI. + """ + try: + from kipy.board_types import Via + from kipy.geometry import Vector2 + from kipy.proto.board.board_types_pb2 import ViaType + from kipy.util.units import from_mm + + board = self._get_board() + + # Create via + via = Via() + via.position = Vector2.from_xy(from_mm(x), from_mm(y)) + via.diameter = from_mm(diameter) + via.drill_diameter = from_mm(drill) + + # Set via type (enum values: VT_THROUGH=1, VT_BLIND_BURIED=2, VT_MICRO=3) + type_map = { + "through": ViaType.VT_THROUGH, + "blind": ViaType.VT_BLIND_BURIED, + "micro": ViaType.VT_MICRO, + } + via.type = type_map.get(via_type, ViaType.VT_THROUGH) + + # Set net if specified + if net_name: + nets = board.get_nets() + for net in nets: + if net.name == net_name: + via.net = net + break + + # Add via with transaction + commit = board.begin_commit() + board.create_items(via) + board.push_commit(commit, "Added via") + + self._notify( + "via_added", + { + "position": {"x": x, "y": y}, + "diameter": diameter, + "drill": drill, + "net": net_name, + "type": via_type, + }, + ) + + logger.info(f"Added via at ({x}, {y}) mm") + return True + + except Exception as e: + logger.error(f"Failed to add via: {e}") + return False + + def add_text( + self, + text: str, + x: float, + y: float, + layer: str = "F.SilkS", + size: float = 1.0, + rotation: float = 0, + ) -> bool: + """Add text to the board.""" + try: + from kipy.board_types import BoardText + from kipy.geometry import Angle, Vector2 + from kipy.proto.board.board_types_pb2 import BoardLayer + from kipy.util.units import from_mm + + board = self._get_board() + + # Create text + board_text = BoardText() + board_text.value = text + board_text.position = Vector2.from_xy(from_mm(x), from_mm(y)) + board_text.angle = Angle.from_degrees(rotation) + + # Set layer + layer_map = { + "F.SilkS": BoardLayer.BL_F_SilkS, + "B.SilkS": BoardLayer.BL_B_SilkS, + "F.Cu": BoardLayer.BL_F_Cu, + "B.Cu": BoardLayer.BL_B_Cu, + } + board_text.layer = layer_map.get(layer, BoardLayer.BL_F_SilkS) + + # Add text with transaction + commit = board.begin_commit() + board.create_items(board_text) + board.push_commit(commit, f"Added text: {text}") + + self._notify("text_added", {"text": text, "position": {"x": x, "y": y}, "layer": layer}) + + return True + + except Exception as e: + logger.error(f"Failed to add text: {e}") + return False + + def get_tracks(self) -> List[Dict[str, Any]]: + """Get all tracks on the board.""" + try: + from kipy.util.units import to_mm + + board = self._get_board() + tracks = board.get_tracks() + + result = [] + for track in tracks: + try: + result.append( + { + "start": {"x": to_mm(track.start.x), "y": to_mm(track.start.y)}, + "end": {"x": to_mm(track.end.x), "y": to_mm(track.end.y)}, + "width": to_mm(track.width), + "layer": str(track.layer), + "net": track.net.name if track.net else "", + "id": str(track.id) if hasattr(track, "id") else "", + } + ) + except Exception as e: + logger.warning(f"Error processing track: {e}") + continue + + return result + + except Exception as e: + logger.error(f"Failed to get tracks: {e}") + return [] + + def get_vias(self) -> List[Dict[str, Any]]: + """Get all vias on the board.""" + try: + from kipy.util.units import to_mm + + board = self._get_board() + vias = board.get_vias() + + result = [] + for via in vias: + try: + result.append( + { + "position": {"x": to_mm(via.position.x), "y": to_mm(via.position.y)}, + "diameter": to_mm(via.diameter), + "drill": to_mm(via.drill_diameter), + "net": via.net.name if via.net else "", + "type": str(via.type), + "id": str(via.id) if hasattr(via, "id") else "", + } + ) + except Exception as e: + logger.warning(f"Error processing via: {e}") + continue + + return result + + except Exception as e: + logger.error(f"Failed to get vias: {e}") + return [] + + def get_nets(self) -> List[Dict[str, Any]]: + """Get all nets on the board.""" + try: + board = self._get_board() + nets = board.get_nets() + + result = [] + for net in nets: + try: + result.append( + {"name": net.name, "code": net.code if hasattr(net, "code") else 0} + ) + except Exception as e: + logger.warning(f"Error processing net: {e}") + continue + + return result + + except Exception as e: + logger.error(f"Failed to get nets: {e}") + return [] + + def add_zone( + self, + points: List[Dict[str, float]], + layer: str = "F.Cu", + net_name: Optional[str] = None, + clearance: float = 0.5, + min_thickness: float = 0.25, + priority: int = 0, + fill_mode: str = "solid", + name: str = "", + ) -> bool: + """ + Add a copper pour zone to the board. + + The zone appears immediately in the KiCAD UI. + + Args: + points: List of points defining the zone outline, e.g. [{"x": 0, "y": 0}, ...] + layer: Layer name (F.Cu, B.Cu, etc.) + net_name: Net to connect the zone to (e.g., "GND") + clearance: Clearance from other copper in mm + min_thickness: Minimum copper thickness in mm + priority: Zone priority (higher = fills first) + fill_mode: "solid" or "hatched" + name: Optional zone name + """ + try: + from kipy.board_types import Zone, ZoneFillMode, ZoneType + from kipy.geometry import PolyLine, PolyLineNode, Vector2 + from kipy.proto.board.board_types_pb2 import BoardLayer + from kipy.util.units import from_mm + + board = self._get_board() + + if len(points) < 3: + logger.error("Zone requires at least 3 points") + return False + + # Create zone + zone = Zone() + zone.type = ZoneType.ZT_COPPER + + # Set layer + layer_map = { + "F.Cu": BoardLayer.BL_F_Cu, + "B.Cu": BoardLayer.BL_B_Cu, + "In1.Cu": BoardLayer.BL_In1_Cu, + "In2.Cu": BoardLayer.BL_In2_Cu, + "In3.Cu": BoardLayer.BL_In3_Cu, + "In4.Cu": BoardLayer.BL_In4_Cu, + } + zone.layers = [layer_map.get(layer, BoardLayer.BL_F_Cu)] + + # Set net if specified + if net_name: + nets = board.get_nets() + for net in nets: + if net.name == net_name: + zone.net = net + break + + # Set zone properties + zone.clearance = from_mm(clearance) + zone.min_thickness = from_mm(min_thickness) + zone.priority = priority + + if name: + zone.name = name + + # Set fill mode + if fill_mode == "hatched": + zone.fill_mode = ZoneFillMode.ZFM_HATCHED + else: + zone.fill_mode = ZoneFillMode.ZFM_SOLID + + # Create outline polyline + outline = PolyLine() + outline.closed = True + + for point in points: + x = point.get("x", 0) + y = point.get("y", 0) + node = PolyLineNode.from_xy(from_mm(x), from_mm(y)) + outline.append(node) + + # Set the outline on the zone + # Note: Zone outline is set via the proto directly since kipy + # doesn't expose a direct setter for creating new zones + zone._proto.outline.polygons.add() + zone._proto.outline.polygons[0].outline.CopyFrom(outline._proto) + + # Add zone with transaction + commit = board.begin_commit() + board.create_items(zone) + board.push_commit(commit, f"Added copper zone on {layer}") + + self._notify( + "zone_added", + {"layer": layer, "net": net_name, "points": len(points), "priority": priority}, + ) + + logger.info(f"Added zone on {layer} with {len(points)} points") + return True + + except Exception as e: + logger.error(f"Failed to add zone: {e}") + return False + + def get_zones(self) -> List[Dict[str, Any]]: + """Get all zones on the board.""" + try: + from kipy.util.units import to_mm + + board = self._get_board() + zones = board.get_zones() + + result = [] + for zone in zones: + try: + result.append( + { + "name": zone.name if hasattr(zone, "name") else "", + "net": zone.net.name if zone.net else "", + "priority": zone.priority if hasattr(zone, "priority") else 0, + "layers": ( + [str(l) for l in zone.layers] if hasattr(zone, "layers") else [] + ), + "filled": zone.filled if hasattr(zone, "filled") else False, + "id": str(zone.id) if hasattr(zone, "id") else "", + } + ) + except Exception as e: + logger.warning(f"Error processing zone: {e}") + continue + + return result + + except Exception as e: + logger.error(f"Failed to get zones: {e}") + return [] + + def refill_zones(self) -> bool: + """Refill all copper pour zones.""" + try: + board = self._get_board() + board.refill_zones() + self._notify("zones_refilled", {}) + return True + except Exception as e: + logger.error(f"Failed to refill zones: {e}") + return False + + def get_selection(self) -> List[Dict[str, Any]]: + """Get currently selected items in the KiCAD UI.""" + try: + board = self._get_board() + selection = board.get_selection() + + result = [] + for item in selection: + result.append( + {"type": type(item).__name__, "id": str(item.id) if hasattr(item, "id") else ""} + ) + + return result + except Exception as e: + logger.error(f"Failed to get selection: {e}") + return [] + + def clear_selection(self) -> bool: + """Clear the current selection in KiCAD UI.""" + try: + board = self._get_board() + board.clear_selection() + return True + except Exception as e: + logger.error(f"Failed to clear selection: {e}") + return False + + +# Export for factory +__all__ = ["IPCBackend", "IPCBoardAPI"] diff --git a/python/kicad_api/swig_backend.py b/python/kicad_api/swig_backend.py index a12b604..7cc3d16 100644 --- a/python/kicad_api/swig_backend.py +++ b/python/kicad_api/swig_backend.py @@ -1,218 +1,218 @@ -""" -SWIG Backend (Legacy - DEPRECATED) - -Uses the legacy SWIG-based pcbnew Python bindings. -This backend wraps the existing implementation for backward compatibility. - -WARNING: SWIG bindings are deprecated as of KiCAD 9.0 - and will be removed in KiCAD 10.0. - Please migrate to IPC backend. -""" - -import logging -from pathlib import Path -from typing import Any, Dict, List, Optional - -from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend - -logger = logging.getLogger(__name__) - - -class SWIGBackend(KiCADBackend): - """ - Legacy SWIG-based backend - - Wraps existing commands/project.py, commands/component.py, etc. - for compatibility during migration period. - """ - - def __init__(self) -> None: - self._connected = False - self._pcbnew = None - logger.warning( - "⚠️ Using DEPRECATED SWIG backend. " - "This will be removed in KiCAD 10.0. " - "Please migrate to IPC API." - ) - - def connect(self) -> bool: - """ - 'Connect' to SWIG API (just validates pcbnew import) - - Returns: - True if pcbnew module available - """ - try: - import pcbnew - - self._pcbnew = pcbnew - version = pcbnew.GetBuildVersion() - logger.info(f"✓ Connected to pcbnew (SWIG): {version}") - self._connected = True - return True - except ImportError as e: - logger.error("pcbnew module not found") - raise APINotAvailableError( - "SWIG backend requires pcbnew module. " - "Ensure KiCAD Python module is in PYTHONPATH." - ) from e - - def disconnect(self) -> None: - """Disconnect from SWIG API (no-op)""" - self._connected = False - self._pcbnew = None - logger.info("Disconnected from SWIG backend") - - def is_connected(self) -> bool: - """Check if connected""" - return self._connected - - def get_version(self) -> str: - """Get KiCAD version""" - if not self.is_connected(): - raise ConnectionError("Not connected") - - return self._pcbnew.GetBuildVersion() - - # Project Operations - def create_project(self, path: Path, name: str) -> Dict[str, Any]: - """Create project using existing SWIG implementation""" - if not self.is_connected(): - raise ConnectionError("Not connected") - - # Import existing implementation - from commands.project import ProjectCommands - - try: - result = ProjectCommands.create_project(str(path), name) - return result - except Exception as e: - logger.error(f"Failed to create project: {e}") - raise - - def open_project(self, path: Path) -> Dict[str, Any]: - """Open project using existing SWIG implementation""" - if not self.is_connected(): - raise ConnectionError("Not connected") - - from commands.project import ProjectCommands - - try: - result = ProjectCommands().open_project({"filename": str(path)}) - return result - except Exception as e: - logger.error(f"Failed to open project: {e}") - raise - - def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]: - """Save project using existing SWIG implementation""" - if not self.is_connected(): - raise ConnectionError("Not connected") - - from commands.project import ProjectCommands - - try: - params: Dict[str, Any] = {} - if path: - params["filename"] = str(path) - result = ProjectCommands().save_project(params) - return result - except Exception as e: - logger.error(f"Failed to save project: {e}") - raise - - def close_project(self) -> None: - """Close project (SWIG doesn't have explicit close)""" - logger.info("Closing project (SWIG backend)") - # SWIG backend doesn't maintain project state, - # so this is essentially a no-op - - # Board Operations - def get_board(self) -> BoardAPI: - """Get board API""" - if not self.is_connected(): - raise ConnectionError("Not connected") - - return SWIGBoardAPI(self._pcbnew) - - -class SWIGBoardAPI(BoardAPI): - """Board API implementation wrapping SWIG/pcbnew""" - - def __init__(self, pcbnew_module: Any) -> None: - self.pcbnew = pcbnew_module - self._board = None - - def set_size(self, width: float, height: float, unit: str = "mm") -> bool: - """Set board size using existing implementation""" - from commands.board import BoardCommands - - try: - result = BoardCommands(board=self._board).set_board_size( - {"width": width, "height": height, "unit": unit} - ) - return result.get("success", False) - except Exception as e: - logger.error(f"Failed to set board size: {e}") - return False - - def get_size(self) -> Dict[str, Any]: - """Get board size""" - # TODO: Implement using existing SWIG code - raise NotImplementedError("get_size not yet wrapped") - - def add_layer(self, layer_name: str, layer_type: str) -> bool: - """Add layer using existing implementation""" - from commands.board import BoardCommands - - try: - result = BoardCommands.add_layer(layer_name, layer_type) - return result.get("success", False) - except Exception as e: - logger.error(f"Failed to add layer: {e}") - return False - - def list_components(self) -> List[Dict[str, Any]]: - """List components using existing implementation""" - from commands.component import ComponentCommands - - try: - result = ComponentCommands(board=self._board).get_component_list({}) - if result.get("success"): - return result.get("components", []) - return [] - except Exception as e: - logger.error(f"Failed to list components: {e}") - return [] - - def place_component( - self, - reference: str, - footprint: str, - x: float, - y: float, - rotation: float = 0, - layer: str = "F.Cu", - value: str = "", - ) -> bool: - """Place component using existing implementation""" - from commands.component import ComponentCommands - - try: - result = ComponentCommands(board=self._board).place_component( - { - "componentId": footprint, - "position": {"x": x, "y": y, "unit": "mm"}, - "reference": reference, - "rotation": rotation, - "layer": layer, - } - ) - return result.get("success", False) - except Exception as e: - logger.error(f"Failed to place component: {e}") - return False - - -# This backend serves as a wrapper during the migration period. -# Once IPC backend is fully implemented, this can be deprecated. +""" +SWIG Backend (Legacy - DEPRECATED) + +Uses the legacy SWIG-based pcbnew Python bindings. +This backend wraps the existing implementation for backward compatibility. + +WARNING: SWIG bindings are deprecated as of KiCAD 9.0 + and will be removed in KiCAD 10.0. + Please migrate to IPC backend. +""" + +import logging +from pathlib import Path +from typing import Any, Dict, List, Optional + +from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend + +logger = logging.getLogger(__name__) + + +class SWIGBackend(KiCADBackend): + """ + Legacy SWIG-based backend + + Wraps existing commands/project.py, commands/component.py, etc. + for compatibility during migration period. + """ + + def __init__(self) -> None: + self._connected = False + self._pcbnew = None + logger.warning( + "⚠️ Using DEPRECATED SWIG backend. " + "This will be removed in KiCAD 10.0. " + "Please migrate to IPC API." + ) + + def connect(self) -> bool: + """ + 'Connect' to SWIG API (just validates pcbnew import) + + Returns: + True if pcbnew module available + """ + try: + import pcbnew + + self._pcbnew = pcbnew + version = pcbnew.GetBuildVersion() + logger.info(f"✓ Connected to pcbnew (SWIG): {version}") + self._connected = True + return True + except ImportError as e: + logger.error("pcbnew module not found") + raise APINotAvailableError( + "SWIG backend requires pcbnew module. " + "Ensure KiCAD Python module is in PYTHONPATH." + ) from e + + def disconnect(self) -> None: + """Disconnect from SWIG API (no-op)""" + self._connected = False + self._pcbnew = None + logger.info("Disconnected from SWIG backend") + + def is_connected(self) -> bool: + """Check if connected""" + return self._connected + + def get_version(self) -> str: + """Get KiCAD version""" + if not self.is_connected(): + raise ConnectionError("Not connected") + + return self._pcbnew.GetBuildVersion() + + # Project Operations + def create_project(self, path: Path, name: str) -> Dict[str, Any]: + """Create project using existing SWIG implementation""" + if not self.is_connected(): + raise ConnectionError("Not connected") + + # Import existing implementation + from commands.project import ProjectCommands + + try: + result = ProjectCommands.create_project(str(path), name) + return result + except Exception as e: + logger.error(f"Failed to create project: {e}") + raise + + def open_project(self, path: Path) -> Dict[str, Any]: + """Open project using existing SWIG implementation""" + if not self.is_connected(): + raise ConnectionError("Not connected") + + from commands.project import ProjectCommands + + try: + result = ProjectCommands().open_project({"filename": str(path)}) + return result + except Exception as e: + logger.error(f"Failed to open project: {e}") + raise + + def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]: + """Save project using existing SWIG implementation""" + if not self.is_connected(): + raise ConnectionError("Not connected") + + from commands.project import ProjectCommands + + try: + params: Dict[str, Any] = {} + if path: + params["filename"] = str(path) + result = ProjectCommands().save_project(params) + return result + except Exception as e: + logger.error(f"Failed to save project: {e}") + raise + + def close_project(self) -> None: + """Close project (SWIG doesn't have explicit close)""" + logger.info("Closing project (SWIG backend)") + # SWIG backend doesn't maintain project state, + # so this is essentially a no-op + + # Board Operations + def get_board(self) -> BoardAPI: + """Get board API""" + if not self.is_connected(): + raise ConnectionError("Not connected") + + return SWIGBoardAPI(self._pcbnew) + + +class SWIGBoardAPI(BoardAPI): + """Board API implementation wrapping SWIG/pcbnew""" + + def __init__(self, pcbnew_module: Any) -> None: + self.pcbnew = pcbnew_module + self._board = None + + def set_size(self, width: float, height: float, unit: str = "mm") -> bool: + """Set board size using existing implementation""" + from commands.board import BoardCommands + + try: + result = BoardCommands(board=self._board).set_board_size( + {"width": width, "height": height, "unit": unit} + ) + return result.get("success", False) + except Exception as e: + logger.error(f"Failed to set board size: {e}") + return False + + def get_size(self) -> Dict[str, Any]: + """Get board size""" + # TODO: Implement using existing SWIG code + raise NotImplementedError("get_size not yet wrapped") + + def add_layer(self, layer_name: str, layer_type: str) -> bool: + """Add layer using existing implementation""" + from commands.board import BoardCommands + + try: + result = BoardCommands.add_layer(layer_name, layer_type) + return result.get("success", False) + except Exception as e: + logger.error(f"Failed to add layer: {e}") + return False + + def list_components(self) -> List[Dict[str, Any]]: + """List components using existing implementation""" + from commands.component import ComponentCommands + + try: + result = ComponentCommands(board=self._board).get_component_list({}) + if result.get("success"): + return result.get("components", []) + return [] + except Exception as e: + logger.error(f"Failed to list components: {e}") + return [] + + def place_component( + self, + reference: str, + footprint: str, + x: float, + y: float, + rotation: float = 0, + layer: str = "F.Cu", + value: str = "", + ) -> bool: + """Place component using existing implementation""" + from commands.component import ComponentCommands + + try: + result = ComponentCommands(board=self._board).place_component( + { + "componentId": footprint, + "position": {"x": x, "y": y, "unit": "mm"}, + "reference": reference, + "rotation": rotation, + "layer": layer, + } + ) + return result.get("success", False) + except Exception as e: + logger.error(f"Failed to place component: {e}") + return False + + +# This backend serves as a wrapper during the migration period. +# Once IPC backend is fully implemented, this can be deprecated. diff --git a/python/parsers/__init__.py b/python/parsers/__init__.py index d90227c..8c02a85 100644 --- a/python/parsers/__init__.py +++ b/python/parsers/__init__.py @@ -1 +1 @@ -# parsers package +# parsers package diff --git a/python/parsers/kicad_mod_parser.py b/python/parsers/kicad_mod_parser.py index a1f2ac0..87718b3 100644 --- a/python/parsers/kicad_mod_parser.py +++ b/python/parsers/kicad_mod_parser.py @@ -1,250 +1,250 @@ -""" -Parser for KiCad .kicad_mod footprint files. - -Extracts the fields that the MCP get_footprint_info tool exposes to clients: - name – footprint name (str) - library – library nickname, injected by caller (str) - description – (descr "…") token (str | None) - keywords – (tags "…") token (str | None) - pads – list of pad objects: [{number, type, shape}, …] (list[dict]) - layers – sorted unique list of canonical layer names used (list[str]) - courtyard – {"width": float, "height": float} from F.CrtYd geometry (dict | None) - attributes – {"type": str, "board_only": bool, …} (dict | None) - -KiCad S-expression file format reference: - https://dev-docs.kicad.org/en/file-formats/sexpr-intro/index.html#_footprint -""" - -import logging -import re -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple - -logger = logging.getLogger("kicad_interface") - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - -def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: - """ - Parse a .kicad_mod file and return a dict whose keys match the fields - expected by the TypeScript MCP tool handler (src/tools/library.ts). - - Returns None if the file does not exist or cannot be read. - """ - path = Path(file_path) - if not path.exists(): - logger.debug(f"parse_kicad_mod: file not found: {file_path}") - return None - - try: - content = path.read_text(encoding="utf-8") - except OSError as e: - logger.warning(f"parse_kicad_mod: cannot read {file_path}: {e}") - return None - - logger.debug(f"parse_kicad_mod: parsing {path.name} ({len(content)} chars)") - - result: Dict[str, Any] = {} - - # ------------------------------------------------------------------ - # Footprint name: (footprint "NAME" … - # Per spec, in a library file the name is the ENTRY_NAME only (no lib prefix). - # ------------------------------------------------------------------ - m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE) - if not m: - # Older / unquoted format - m = re.search(r"^\s*\(footprint\s+(\S+)", content, re.MULTILINE) - result["name"] = _unescape(m.group(1)) if m else path.stem - logger.debug(f"parse_kicad_mod: name={result['name']!r}") - - # ------------------------------------------------------------------ - # Description: (descr "…") - # ------------------------------------------------------------------ - m = re.search(r'\(descr\s+"((?:[^"\\]|\\.)*)"\)', content) - result["description"] = _unescape(m.group(1)) if m else None - logger.debug(f"parse_kicad_mod: description={result['description']!r}") - - # ------------------------------------------------------------------ - # Keywords / tags: (tags "…") - # ------------------------------------------------------------------ - m = re.search(r'\(tags\s+"((?:[^"\\]|\\.)*)"\)', content) - result["keywords"] = _unescape(m.group(1)) if m else None - logger.debug(f"parse_kicad_mod: keywords={result['keywords']!r}") - - # ------------------------------------------------------------------ - # Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom]) - # TYPE is smd | through_hole (no quotes) - # ------------------------------------------------------------------ - m = re.search(r"\(attr\s+([^)]+)\)", content) - if m: - tokens = m.group(1).split() - result["attributes"] = { - "type": tokens[0] if tokens else "unspecified", - "board_only": "board_only" in tokens, - "exclude_from_pos_files": "exclude_from_pos_files" in tokens, - "exclude_from_bom": "exclude_from_bom" in tokens, - } - else: - result["attributes"] = None - logger.debug(f"parse_kicad_mod: attributes={result['attributes']!r}") - - # ------------------------------------------------------------------ - # Pads: (pad "NUMBER" TYPE SHAPE …) - # Return each pad as an object; deduplicate by number (first wins). - # ------------------------------------------------------------------ - result["pads"] = _extract_pads(content) - logger.debug(f"parse_kicad_mod: pads count={len(result['pads'])}, pads={result['pads']}") - - # ------------------------------------------------------------------ - # Layers: all unique canonical layer names across the whole file. - # Sources: - # (layer "NAME") – single-layer items (fp_line, fp_text, …) - # (layers "A" "B" …) – pad layer lists - # ------------------------------------------------------------------ - layers: set = set() - for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content): - layers.add(m.group(1)) - for m in re.finditer(r"\(layers\s+([^)]+)\)", content): - for lyr in re.findall(r'"([^"]+)"', m.group(1)): - layers.add(lyr) - result["layers"] = sorted(layers) - logger.debug(f"parse_kicad_mod: layers={result['layers']}") - - # ------------------------------------------------------------------ - # Courtyard: derive bounding box from F.CrtYd geometry. - # Prefer fp_rect (most common for standard footprints), fall back to - # fp_line segments. - # ------------------------------------------------------------------ - result["courtyard"] = _extract_courtyard(content) - logger.debug(f"parse_kicad_mod: courtyard={result['courtyard']!r}") - - return result - - -# --------------------------------------------------------------------------- -# Internal helpers -# --------------------------------------------------------------------------- - - -def _extract_pads(content: str) -> List[Dict[str, Any]]: - """ - Parse all (pad …) blocks and return a list of pad objects. - - Each object has: - number – pad number string, e.g. "1", "A1", "GND" - type – thru_hole | smd | np_thru_hole | connect - shape – rect | circle | oval | roundrect | trapezoid | custom - - Pads are deduplicated by number (first occurrence wins) so that the - list represents the logical pads of the footprint, not duplicated - copper entries. - """ - pads: List[Dict[str, Any]] = [] - seen_numbers: dict = {} - - # KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …) - quoted_pattern = re.compile( - r'\(pad\s+"([^"]*)"\s+' - r"(thru_hole|smd|np_thru_hole|connect)\s+" - r"(rect|circle|oval|roundrect|trapezoid|custom)\b" - ) - for m in quoted_pattern.finditer(content): - number, ptype, shape = m.group(1), m.group(2), m.group(3) - if number not in seen_numbers: - seen_numbers[number] = True - pads.append({"number": number, "type": ptype, "shape": shape}) - - if not pads: - # Older / unquoted format: (pad NUMBER TYPE SHAPE …) - unquoted_pattern = re.compile( - r"\(pad\s+(\S+)\s+" - r"(thru_hole|smd|np_thru_hole|connect)\s+" - r"(rect|circle|oval|roundrect|trapezoid|custom)\b" - ) - for m in unquoted_pattern.finditer(content): - number, ptype, shape = m.group(1), m.group(2), m.group(3) - if number not in seen_numbers: - seen_numbers[number] = True - pads.append({"number": number, "type": ptype, "shape": shape}) - - return pads - - -def _unescape(s: str) -> str: - """Reverse KiCad S-expression string escaping.""" - return s.replace('\\"', '"').replace("\\\\", "\\") - - -def _extract_blocks(content: str, token: str) -> List[str]: - """ - Return all S-expression blocks that start with `(token ` by tracking - parenthesis depth. This correctly handles nested parens inside blocks. - """ - blocks: List[str] = [] - pattern = re.compile(r"\(" + re.escape(token) + r"\b") - - for match in pattern.finditer(content): - start = match.start() - depth = 0 - i = start - while i < len(content): - ch = content[i] - if ch == "(": - depth += 1 - elif ch == ")": - depth -= 1 - if depth == 0: - blocks.append(content[start : i + 1]) - break - i += 1 - - return blocks - - -def _extract_courtyard(content: str) -> Optional[Dict[str, float]]: - """ - Compute the courtyard bounding box from F.CrtYd geometry. - - Strategy: - 1. Try fp_rect blocks on F.CrtYd — derive width/height from start/end. - 2. Fall back to fp_line segments on F.CrtYd — compute bounding box of - all endpoints. - """ - xs: List[float] = [] - ys: List[float] = [] - - # --- fp_rect pass --- - for block in _extract_blocks(content, "fp_rect"): - if "F.CrtYd" not in block: - continue - s = re.search(r"\(start\s+([-\d.]+)\s+([-\d.]+)\)", block) - e = re.search(r"\(end\s+([-\d.]+)\s+([-\d.]+)\)", block) - if s and e: - xs += [float(s.group(1)), float(e.group(1))] - ys += [float(s.group(2)), float(e.group(2))] - logger.debug( - f"_extract_courtyard: fp_rect F.CrtYd " - f"start=({s.group(1)},{s.group(2)}) end=({e.group(1)},{e.group(2)})" - ) - - # --- fp_line pass (only if fp_rect found nothing) --- - if not xs: - for block in _extract_blocks(content, "fp_line"): - if "F.CrtYd" not in block: - continue - for m in re.finditer(r"\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)", block): - xs.append(float(m.group(1))) - ys.append(float(m.group(2))) - - if not xs: - logger.debug("_extract_courtyard: no F.CrtYd geometry found") - return None - - width = round(abs(max(xs) - min(xs)), 6) - height = round(abs(max(ys) - min(ys)), 6) - logger.debug(f"_extract_courtyard: result width={width} height={height}") - return {"width": width, "height": height} +""" +Parser for KiCad .kicad_mod footprint files. + +Extracts the fields that the MCP get_footprint_info tool exposes to clients: + name – footprint name (str) + library – library nickname, injected by caller (str) + description – (descr "…") token (str | None) + keywords – (tags "…") token (str | None) + pads – list of pad objects: [{number, type, shape}, …] (list[dict]) + layers – sorted unique list of canonical layer names used (list[str]) + courtyard – {"width": float, "height": float} from F.CrtYd geometry (dict | None) + attributes – {"type": str, "board_only": bool, …} (dict | None) + +KiCad S-expression file format reference: + https://dev-docs.kicad.org/en/file-formats/sexpr-intro/index.html#_footprint +""" + +import logging +import re +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger("kicad_interface") + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: + """ + Parse a .kicad_mod file and return a dict whose keys match the fields + expected by the TypeScript MCP tool handler (src/tools/library.ts). + + Returns None if the file does not exist or cannot be read. + """ + path = Path(file_path) + if not path.exists(): + logger.debug(f"parse_kicad_mod: file not found: {file_path}") + return None + + try: + content = path.read_text(encoding="utf-8") + except OSError as e: + logger.warning(f"parse_kicad_mod: cannot read {file_path}: {e}") + return None + + logger.debug(f"parse_kicad_mod: parsing {path.name} ({len(content)} chars)") + + result: Dict[str, Any] = {} + + # ------------------------------------------------------------------ + # Footprint name: (footprint "NAME" … + # Per spec, in a library file the name is the ENTRY_NAME only (no lib prefix). + # ------------------------------------------------------------------ + m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE) + if not m: + # Older / unquoted format + m = re.search(r"^\s*\(footprint\s+(\S+)", content, re.MULTILINE) + result["name"] = _unescape(m.group(1)) if m else path.stem + logger.debug(f"parse_kicad_mod: name={result['name']!r}") + + # ------------------------------------------------------------------ + # Description: (descr "…") + # ------------------------------------------------------------------ + m = re.search(r'\(descr\s+"((?:[^"\\]|\\.)*)"\)', content) + result["description"] = _unescape(m.group(1)) if m else None + logger.debug(f"parse_kicad_mod: description={result['description']!r}") + + # ------------------------------------------------------------------ + # Keywords / tags: (tags "…") + # ------------------------------------------------------------------ + m = re.search(r'\(tags\s+"((?:[^"\\]|\\.)*)"\)', content) + result["keywords"] = _unescape(m.group(1)) if m else None + logger.debug(f"parse_kicad_mod: keywords={result['keywords']!r}") + + # ------------------------------------------------------------------ + # Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom]) + # TYPE is smd | through_hole (no quotes) + # ------------------------------------------------------------------ + m = re.search(r"\(attr\s+([^)]+)\)", content) + if m: + tokens = m.group(1).split() + result["attributes"] = { + "type": tokens[0] if tokens else "unspecified", + "board_only": "board_only" in tokens, + "exclude_from_pos_files": "exclude_from_pos_files" in tokens, + "exclude_from_bom": "exclude_from_bom" in tokens, + } + else: + result["attributes"] = None + logger.debug(f"parse_kicad_mod: attributes={result['attributes']!r}") + + # ------------------------------------------------------------------ + # Pads: (pad "NUMBER" TYPE SHAPE …) + # Return each pad as an object; deduplicate by number (first wins). + # ------------------------------------------------------------------ + result["pads"] = _extract_pads(content) + logger.debug(f"parse_kicad_mod: pads count={len(result['pads'])}, pads={result['pads']}") + + # ------------------------------------------------------------------ + # Layers: all unique canonical layer names across the whole file. + # Sources: + # (layer "NAME") – single-layer items (fp_line, fp_text, …) + # (layers "A" "B" …) – pad layer lists + # ------------------------------------------------------------------ + layers: set = set() + for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content): + layers.add(m.group(1)) + for m in re.finditer(r"\(layers\s+([^)]+)\)", content): + for lyr in re.findall(r'"([^"]+)"', m.group(1)): + layers.add(lyr) + result["layers"] = sorted(layers) + logger.debug(f"parse_kicad_mod: layers={result['layers']}") + + # ------------------------------------------------------------------ + # Courtyard: derive bounding box from F.CrtYd geometry. + # Prefer fp_rect (most common for standard footprints), fall back to + # fp_line segments. + # ------------------------------------------------------------------ + result["courtyard"] = _extract_courtyard(content) + logger.debug(f"parse_kicad_mod: courtyard={result['courtyard']!r}") + + return result + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _extract_pads(content: str) -> List[Dict[str, Any]]: + """ + Parse all (pad …) blocks and return a list of pad objects. + + Each object has: + number – pad number string, e.g. "1", "A1", "GND" + type – thru_hole | smd | np_thru_hole | connect + shape – rect | circle | oval | roundrect | trapezoid | custom + + Pads are deduplicated by number (first occurrence wins) so that the + list represents the logical pads of the footprint, not duplicated + copper entries. + """ + pads: List[Dict[str, Any]] = [] + seen_numbers: dict = {} + + # KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …) + quoted_pattern = re.compile( + r'\(pad\s+"([^"]*)"\s+' + r"(thru_hole|smd|np_thru_hole|connect)\s+" + r"(rect|circle|oval|roundrect|trapezoid|custom)\b" + ) + for m in quoted_pattern.finditer(content): + number, ptype, shape = m.group(1), m.group(2), m.group(3) + if number not in seen_numbers: + seen_numbers[number] = True + pads.append({"number": number, "type": ptype, "shape": shape}) + + if not pads: + # Older / unquoted format: (pad NUMBER TYPE SHAPE …) + unquoted_pattern = re.compile( + r"\(pad\s+(\S+)\s+" + r"(thru_hole|smd|np_thru_hole|connect)\s+" + r"(rect|circle|oval|roundrect|trapezoid|custom)\b" + ) + for m in unquoted_pattern.finditer(content): + number, ptype, shape = m.group(1), m.group(2), m.group(3) + if number not in seen_numbers: + seen_numbers[number] = True + pads.append({"number": number, "type": ptype, "shape": shape}) + + return pads + + +def _unescape(s: str) -> str: + """Reverse KiCad S-expression string escaping.""" + return s.replace('\\"', '"').replace("\\\\", "\\") + + +def _extract_blocks(content: str, token: str) -> List[str]: + """ + Return all S-expression blocks that start with `(token ` by tracking + parenthesis depth. This correctly handles nested parens inside blocks. + """ + blocks: List[str] = [] + pattern = re.compile(r"\(" + re.escape(token) + r"\b") + + for match in pattern.finditer(content): + start = match.start() + depth = 0 + i = start + while i < len(content): + ch = content[i] + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + blocks.append(content[start : i + 1]) + break + i += 1 + + return blocks + + +def _extract_courtyard(content: str) -> Optional[Dict[str, float]]: + """ + Compute the courtyard bounding box from F.CrtYd geometry. + + Strategy: + 1. Try fp_rect blocks on F.CrtYd — derive width/height from start/end. + 2. Fall back to fp_line segments on F.CrtYd — compute bounding box of + all endpoints. + """ + xs: List[float] = [] + ys: List[float] = [] + + # --- fp_rect pass --- + for block in _extract_blocks(content, "fp_rect"): + if "F.CrtYd" not in block: + continue + s = re.search(r"\(start\s+([-\d.]+)\s+([-\d.]+)\)", block) + e = re.search(r"\(end\s+([-\d.]+)\s+([-\d.]+)\)", block) + if s and e: + xs += [float(s.group(1)), float(e.group(1))] + ys += [float(s.group(2)), float(e.group(2))] + logger.debug( + f"_extract_courtyard: fp_rect F.CrtYd " + f"start=({s.group(1)},{s.group(2)}) end=({e.group(1)},{e.group(2)})" + ) + + # --- fp_line pass (only if fp_rect found nothing) --- + if not xs: + for block in _extract_blocks(content, "fp_line"): + if "F.CrtYd" not in block: + continue + for m in re.finditer(r"\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)", block): + xs.append(float(m.group(1))) + ys.append(float(m.group(2))) + + if not xs: + logger.debug("_extract_courtyard: no F.CrtYd geometry found") + return None + + width = round(abs(max(xs) - min(xs)), 6) + height = round(abs(max(ys) - min(ys)), 6) + logger.debug(f"_extract_courtyard: result width={width} height={height}") + return {"width": width, "height": height} diff --git a/python/requirements.txt b/python/requirements.txt index 136ab36..71f439c 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -1,13 +1,13 @@ -# KiCAD MCP Python Interface Requirements - -# Image processing -Pillow>=9.0.0 -cairosvg>=2.7.0 - -# Type hints -typing-extensions>=4.0.0 - -# Logging -colorlog>=6.7.0 - -kicad-skip +# KiCAD MCP Python Interface Requirements + +# Image processing +Pillow>=9.0.0 +cairosvg>=2.7.0 + +# Type hints +typing-extensions>=4.0.0 + +# Logging +colorlog>=6.7.0 + +kicad-skip diff --git a/python/resources/__init__.py b/python/resources/__init__.py index 782f961..044f7cd 100644 --- a/python/resources/__init__.py +++ b/python/resources/__init__.py @@ -1,7 +1,7 @@ -""" -Resource definitions for KiCAD MCP Server -""" - -from .resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read - -__all__ = ["RESOURCE_DEFINITIONS", "handle_resource_read"] +""" +Resource definitions for KiCAD MCP Server +""" + +from .resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read + +__all__ = ["RESOURCE_DEFINITIONS", "handle_resource_read"] diff --git a/python/resources/resource_definitions.py b/python/resources/resource_definitions.py index 5919b3e..36fde7b 100644 --- a/python/resources/resource_definitions.py +++ b/python/resources/resource_definitions.py @@ -1,342 +1,342 @@ -""" -Resource definitions for exposing KiCAD project state via MCP - -Resources follow the MCP 2025-06-18 specification, providing -read-only access to project data for LLM context. -""" - -import base64 -import json -import logging -from typing import Any, Dict, List, Optional - -logger = logging.getLogger("kicad_interface") - -# ============================================================================= -# RESOURCE DEFINITIONS -# ============================================================================= - -RESOURCE_DEFINITIONS = [ - { - "uri": "kicad://project/current/info", - "name": "Current Project Information", - "description": "Metadata about the currently open KiCAD project including paths, name, and status", - "mimeType": "application/json", - }, - { - "uri": "kicad://project/current/board", - "name": "Board Properties", - "description": "Comprehensive board information including dimensions, layer count, and design rules", - "mimeType": "application/json", - }, - { - "uri": "kicad://project/current/components", - "name": "Component List", - "description": "List of all components on the board with references, footprints, values, and positions", - "mimeType": "application/json", - }, - { - "uri": "kicad://project/current/nets", - "name": "Electrical Nets", - "description": "List of all electrical nets and their connections", - "mimeType": "application/json", - }, - { - "uri": "kicad://project/current/layers", - "name": "Layer Stack", - "description": "Board layer configuration and properties", - "mimeType": "application/json", - }, - { - "uri": "kicad://project/current/design-rules", - "name": "Design Rules", - "description": "Current design rule settings for clearances, track widths, and constraints", - "mimeType": "application/json", - }, - { - "uri": "kicad://project/current/drc-report", - "name": "DRC Violations", - "description": "Design Rule Check violations and warnings from last DRC run", - "mimeType": "application/json", - }, - { - "uri": "kicad://board/preview.png", - "name": "Board Preview Image", - "description": "2D rendering of the current board state", - "mimeType": "image/png", - }, -] - -# ============================================================================= -# RESOURCE READ HANDLERS -# ============================================================================= - - -def handle_resource_read(uri: str, interface: Any) -> Dict[str, Any]: - """ - Handle reading a resource by URI - - Args: - uri: Resource URI to read - interface: KiCADInterface instance with access to board state - - Returns: - Dict with resource contents following MCP spec - """ - logger.info(f"Reading resource: {uri}") - - handlers = { - "kicad://project/current/info": _get_project_info, - "kicad://project/current/board": _get_board_info, - "kicad://project/current/components": _get_components, - "kicad://project/current/nets": _get_nets, - "kicad://project/current/layers": _get_layers, - "kicad://project/current/design-rules": _get_design_rules, - "kicad://project/current/drc-report": _get_drc_report, - "kicad://board/preview.png": _get_board_preview, - } - - handler = handlers.get(uri) - if handler: - try: - return handler(interface) - except Exception as e: - logger.error(f"Error reading resource {uri}: {str(e)}") - return { - "contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Error: {str(e)}"}] - } - else: - return { - "contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}] - } - - -# ============================================================================= -# INDIVIDUAL RESOURCE HANDLERS -# ============================================================================= - - -def _get_project_info(interface: Any) -> Dict[str, Any]: - """Get current project information""" - result = interface.project_commands.get_project_info({}) - - if result.get("success"): - return { - "contents": [ - { - "uri": "kicad://project/current/info", - "mimeType": "application/json", - "text": json.dumps(result.get("project", {}), indent=2), - } - ] - } - else: - return { - "contents": [ - { - "uri": "kicad://project/current/info", - "mimeType": "text/plain", - "text": "No project currently open", - } - ] - } - - -def _get_board_info(interface: Any) -> Dict[str, Any]: - """Get board properties and metadata""" - result = interface.board_commands.get_board_info({}) - - if result.get("success"): - return { - "contents": [ - { - "uri": "kicad://project/current/board", - "mimeType": "application/json", - "text": json.dumps(result.get("board", {}), indent=2), - } - ] - } - else: - return { - "contents": [ - { - "uri": "kicad://project/current/board", - "mimeType": "text/plain", - "text": "No board currently loaded", - } - ] - } - - -def _get_components(interface: Any) -> Dict[str, Any]: - """Get list of all components""" - result = interface.component_commands.get_component_list({}) - - if result.get("success"): - components = result.get("components", []) - return { - "contents": [ - { - "uri": "kicad://project/current/components", - "mimeType": "application/json", - "text": json.dumps( - {"count": len(components), "components": components}, indent=2 - ), - } - ] - } - else: - return { - "contents": [ - { - "uri": "kicad://project/current/components", - "mimeType": "application/json", - "text": json.dumps({"count": 0, "components": []}, indent=2), - } - ] - } - - -def _get_nets(interface: Any) -> Dict[str, Any]: - """Get list of electrical nets""" - result = interface.routing_commands.get_nets_list({}) - - if result.get("success"): - nets = result.get("nets", []) - return { - "contents": [ - { - "uri": "kicad://project/current/nets", - "mimeType": "application/json", - "text": json.dumps({"count": len(nets), "nets": nets}, indent=2), - } - ] - } - else: - return { - "contents": [ - { - "uri": "kicad://project/current/nets", - "mimeType": "application/json", - "text": json.dumps({"count": 0, "nets": []}, indent=2), - } - ] - } - - -def _get_layers(interface: Any) -> Dict[str, Any]: - """Get layer stack information""" - result = interface.board_commands.get_layer_list({}) - - if result.get("success"): - layers = result.get("layers", []) - return { - "contents": [ - { - "uri": "kicad://project/current/layers", - "mimeType": "application/json", - "text": json.dumps({"count": len(layers), "layers": layers}, indent=2), - } - ] - } - else: - return { - "contents": [ - { - "uri": "kicad://project/current/layers", - "mimeType": "application/json", - "text": json.dumps({"count": 0, "layers": []}, indent=2), - } - ] - } - - -def _get_design_rules(interface: Any) -> Dict[str, Any]: - """Get design rule settings""" - result = interface.design_rule_commands.get_design_rules({}) - - if result.get("success"): - return { - "contents": [ - { - "uri": "kicad://project/current/design-rules", - "mimeType": "application/json", - "text": json.dumps(result.get("rules", {}), indent=2), - } - ] - } - else: - return { - "contents": [ - { - "uri": "kicad://project/current/design-rules", - "mimeType": "text/plain", - "text": "Design rules not available", - } - ] - } - - -def _get_drc_report(interface: Any) -> Dict[str, Any]: - """Get DRC violations""" - result = interface.design_rule_commands.get_drc_violations({}) - - if result.get("success"): - violations = result.get("violations", []) - return { - "contents": [ - { - "uri": "kicad://project/current/drc-report", - "mimeType": "application/json", - "text": json.dumps( - {"count": len(violations), "violations": violations}, indent=2 - ), - } - ] - } - else: - return { - "contents": [ - { - "uri": "kicad://project/current/drc-report", - "mimeType": "application/json", - "text": json.dumps( - { - "count": 0, - "violations": [], - "message": "Run DRC first to get violations", - }, - indent=2, - ), - } - ] - } - - -def _get_board_preview(interface: Any) -> Dict[str, Any]: - """Get board preview as PNG image""" - result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600}) - - if result.get("success") and "imageData" in result: - # Image data should already be base64 encoded - image_data = result.get("imageData", "") - return { - "contents": [ - { - "uri": "kicad://board/preview.png", - "mimeType": "image/png", - "blob": image_data, # Base64 encoded PNG - } - ] - } - else: - # Return a placeholder message - return { - "contents": [ - { - "uri": "kicad://board/preview.png", - "mimeType": "text/plain", - "text": "Board preview not available", - } - ] - } +""" +Resource definitions for exposing KiCAD project state via MCP + +Resources follow the MCP 2025-06-18 specification, providing +read-only access to project data for LLM context. +""" + +import base64 +import json +import logging +from typing import Any, Dict, List, Optional + +logger = logging.getLogger("kicad_interface") + +# ============================================================================= +# RESOURCE DEFINITIONS +# ============================================================================= + +RESOURCE_DEFINITIONS = [ + { + "uri": "kicad://project/current/info", + "name": "Current Project Information", + "description": "Metadata about the currently open KiCAD project including paths, name, and status", + "mimeType": "application/json", + }, + { + "uri": "kicad://project/current/board", + "name": "Board Properties", + "description": "Comprehensive board information including dimensions, layer count, and design rules", + "mimeType": "application/json", + }, + { + "uri": "kicad://project/current/components", + "name": "Component List", + "description": "List of all components on the board with references, footprints, values, and positions", + "mimeType": "application/json", + }, + { + "uri": "kicad://project/current/nets", + "name": "Electrical Nets", + "description": "List of all electrical nets and their connections", + "mimeType": "application/json", + }, + { + "uri": "kicad://project/current/layers", + "name": "Layer Stack", + "description": "Board layer configuration and properties", + "mimeType": "application/json", + }, + { + "uri": "kicad://project/current/design-rules", + "name": "Design Rules", + "description": "Current design rule settings for clearances, track widths, and constraints", + "mimeType": "application/json", + }, + { + "uri": "kicad://project/current/drc-report", + "name": "DRC Violations", + "description": "Design Rule Check violations and warnings from last DRC run", + "mimeType": "application/json", + }, + { + "uri": "kicad://board/preview.png", + "name": "Board Preview Image", + "description": "2D rendering of the current board state", + "mimeType": "image/png", + }, +] + +# ============================================================================= +# RESOURCE READ HANDLERS +# ============================================================================= + + +def handle_resource_read(uri: str, interface: Any) -> Dict[str, Any]: + """ + Handle reading a resource by URI + + Args: + uri: Resource URI to read + interface: KiCADInterface instance with access to board state + + Returns: + Dict with resource contents following MCP spec + """ + logger.info(f"Reading resource: {uri}") + + handlers = { + "kicad://project/current/info": _get_project_info, + "kicad://project/current/board": _get_board_info, + "kicad://project/current/components": _get_components, + "kicad://project/current/nets": _get_nets, + "kicad://project/current/layers": _get_layers, + "kicad://project/current/design-rules": _get_design_rules, + "kicad://project/current/drc-report": _get_drc_report, + "kicad://board/preview.png": _get_board_preview, + } + + handler = handlers.get(uri) + if handler: + try: + return handler(interface) + except Exception as e: + logger.error(f"Error reading resource {uri}: {str(e)}") + return { + "contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Error: {str(e)}"}] + } + else: + return { + "contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}] + } + + +# ============================================================================= +# INDIVIDUAL RESOURCE HANDLERS +# ============================================================================= + + +def _get_project_info(interface: Any) -> Dict[str, Any]: + """Get current project information""" + result = interface.project_commands.get_project_info({}) + + if result.get("success"): + return { + "contents": [ + { + "uri": "kicad://project/current/info", + "mimeType": "application/json", + "text": json.dumps(result.get("project", {}), indent=2), + } + ] + } + else: + return { + "contents": [ + { + "uri": "kicad://project/current/info", + "mimeType": "text/plain", + "text": "No project currently open", + } + ] + } + + +def _get_board_info(interface: Any) -> Dict[str, Any]: + """Get board properties and metadata""" + result = interface.board_commands.get_board_info({}) + + if result.get("success"): + return { + "contents": [ + { + "uri": "kicad://project/current/board", + "mimeType": "application/json", + "text": json.dumps(result.get("board", {}), indent=2), + } + ] + } + else: + return { + "contents": [ + { + "uri": "kicad://project/current/board", + "mimeType": "text/plain", + "text": "No board currently loaded", + } + ] + } + + +def _get_components(interface: Any) -> Dict[str, Any]: + """Get list of all components""" + result = interface.component_commands.get_component_list({}) + + if result.get("success"): + components = result.get("components", []) + return { + "contents": [ + { + "uri": "kicad://project/current/components", + "mimeType": "application/json", + "text": json.dumps( + {"count": len(components), "components": components}, indent=2 + ), + } + ] + } + else: + return { + "contents": [ + { + "uri": "kicad://project/current/components", + "mimeType": "application/json", + "text": json.dumps({"count": 0, "components": []}, indent=2), + } + ] + } + + +def _get_nets(interface: Any) -> Dict[str, Any]: + """Get list of electrical nets""" + result = interface.routing_commands.get_nets_list({}) + + if result.get("success"): + nets = result.get("nets", []) + return { + "contents": [ + { + "uri": "kicad://project/current/nets", + "mimeType": "application/json", + "text": json.dumps({"count": len(nets), "nets": nets}, indent=2), + } + ] + } + else: + return { + "contents": [ + { + "uri": "kicad://project/current/nets", + "mimeType": "application/json", + "text": json.dumps({"count": 0, "nets": []}, indent=2), + } + ] + } + + +def _get_layers(interface: Any) -> Dict[str, Any]: + """Get layer stack information""" + result = interface.board_commands.get_layer_list({}) + + if result.get("success"): + layers = result.get("layers", []) + return { + "contents": [ + { + "uri": "kicad://project/current/layers", + "mimeType": "application/json", + "text": json.dumps({"count": len(layers), "layers": layers}, indent=2), + } + ] + } + else: + return { + "contents": [ + { + "uri": "kicad://project/current/layers", + "mimeType": "application/json", + "text": json.dumps({"count": 0, "layers": []}, indent=2), + } + ] + } + + +def _get_design_rules(interface: Any) -> Dict[str, Any]: + """Get design rule settings""" + result = interface.design_rule_commands.get_design_rules({}) + + if result.get("success"): + return { + "contents": [ + { + "uri": "kicad://project/current/design-rules", + "mimeType": "application/json", + "text": json.dumps(result.get("rules", {}), indent=2), + } + ] + } + else: + return { + "contents": [ + { + "uri": "kicad://project/current/design-rules", + "mimeType": "text/plain", + "text": "Design rules not available", + } + ] + } + + +def _get_drc_report(interface: Any) -> Dict[str, Any]: + """Get DRC violations""" + result = interface.design_rule_commands.get_drc_violations({}) + + if result.get("success"): + violations = result.get("violations", []) + return { + "contents": [ + { + "uri": "kicad://project/current/drc-report", + "mimeType": "application/json", + "text": json.dumps( + {"count": len(violations), "violations": violations}, indent=2 + ), + } + ] + } + else: + return { + "contents": [ + { + "uri": "kicad://project/current/drc-report", + "mimeType": "application/json", + "text": json.dumps( + { + "count": 0, + "violations": [], + "message": "Run DRC first to get violations", + }, + indent=2, + ), + } + ] + } + + +def _get_board_preview(interface: Any) -> Dict[str, Any]: + """Get board preview as PNG image""" + result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600}) + + if result.get("success") and "imageData" in result: + # Image data should already be base64 encoded + image_data = result.get("imageData", "") + return { + "contents": [ + { + "uri": "kicad://board/preview.png", + "mimeType": "image/png", + "blob": image_data, # Base64 encoded PNG + } + ] + } + else: + # Return a placeholder message + return { + "contents": [ + { + "uri": "kicad://board/preview.png", + "mimeType": "text/plain", + "text": "Board preview not available", + } + ] + } diff --git a/python/schemas/__init__.py b/python/schemas/__init__.py index 8fbf767..4066487 100644 --- a/python/schemas/__init__.py +++ b/python/schemas/__init__.py @@ -1,7 +1,7 @@ -""" -Tool schema definitions for KiCAD MCP Server -""" - -from .tool_schemas import TOOL_SCHEMAS - -__all__ = ["TOOL_SCHEMAS"] +""" +Tool schema definitions for KiCAD MCP Server +""" + +from .tool_schemas import TOOL_SCHEMAS + +__all__ = ["TOOL_SCHEMAS"] diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 0d9c410..d63ac4a 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1,1980 +1,1980 @@ -""" -Comprehensive tool schema definitions for all KiCAD MCP commands - -Following MCP 2025-06-18 specification for tool definitions. -Each tool includes: -- name: Unique identifier -- title: Human-readable display name -- description: Detailed explanation of what the tool does -- inputSchema: JSON Schema for parameters -- outputSchema: Optional JSON Schema for return values (structured content) -""" - -from typing import Any, Dict - -# ============================================================================= -# PROJECT TOOLS -# ============================================================================= - -PROJECT_TOOLS = [ - { - "name": "create_project", - "title": "Create New KiCAD Project", - "description": "Creates a new KiCAD project with PCB board file and optional project configuration. Automatically creates project directory and initializes board with default settings.", - "inputSchema": { - "type": "object", - "properties": { - "projectName": { - "type": "string", - "description": "Name of the project (used for file naming)", - "minLength": 1, - }, - "path": { - "type": "string", - "description": "Directory path where project will be created (defaults to current working directory)", - }, - "template": { - "type": "string", - "description": "Optional path to template board file to copy settings from", - }, - }, - "required": ["projectName"], - }, - }, - { - "name": "open_project", - "title": "Open Existing KiCAD Project", - "description": "Opens an existing KiCAD project file (.kicad_pro or .kicad_pcb) and loads the board into memory for manipulation.", - "inputSchema": { - "type": "object", - "properties": { - "filename": { - "type": "string", - "description": "Path to .kicad_pro or .kicad_pcb file", - } - }, - "required": ["filename"], - }, - }, - { - "name": "save_project", - "title": "Save Current Project", - "description": "Saves the current board to disk. Can optionally save to a new location.", - "inputSchema": { - "type": "object", - "properties": { - "filename": { - "type": "string", - "description": "Optional new path to save the board (if not provided, saves to current location)", - } - }, - }, - }, - { - "name": "snapshot_project", - "title": "Snapshot Project (Checkpoint)", - "description": "Copies the entire project folder to a new timestamped snapshot directory so you can resume from this checkpoint later without redoing earlier steps. Call this after every successfully completed design step (e.g. after Step 1 schematic, after Step 2 PCB layout) before asking for user confirmation to proceed.", - "inputSchema": { - "type": "object", - "properties": { - "step": { - "type": "string", - "description": "Step number or name to include in snapshot folder name, e.g. '1' or '2'", - }, - "label": { - "type": "string", - "description": "Optional short label, e.g. 'schematic_ok' or 'layout_ok'", - }, - "projectPath": { - "type": "string", - "description": "Project directory path. Auto-detected from loaded board if omitted.", - }, - }, - }, - }, - { - "name": "get_project_info", - "title": "Get Project Information", - "description": "Retrieves metadata and properties of the currently open project including name, paths, and board status.", - "inputSchema": {"type": "object", "properties": {}}, - }, -] - -# ============================================================================= -# BOARD TOOLS -# ============================================================================= - -BOARD_TOOLS = [ - { - "name": "set_board_size", - "title": "Set Board Dimensions", - "description": "Sets the PCB board dimensions. The board outline must be added separately using add_board_outline.", - "inputSchema": { - "type": "object", - "properties": { - "width": { - "type": "number", - "description": "Board width in millimeters", - "minimum": 1, - }, - "height": { - "type": "number", - "description": "Board height in millimeters", - "minimum": 1, - }, - }, - "required": ["width", "height"], - }, - }, - { - "name": "add_board_outline", - "title": "Add Board Outline", - "description": "Adds a board outline shape (rectangle, rounded_rectangle, circle, or polygon) on the Edge.Cuts layer. By default the board top-left corner is placed at (0, 0) so all coordinates are positive. Use x/y to set a different top-left corner position.", - "inputSchema": { - "type": "object", - "properties": { - "shape": { - "type": "string", - "enum": ["rectangle", "rounded_rectangle", "circle", "polygon"], - "description": "Shape type for the board outline", - }, - "width": { - "type": "number", - "description": "Width in mm (for rectangle/rounded_rectangle)", - "minimum": 1, - }, - "height": { - "type": "number", - "description": "Height in mm (for rectangle/rounded_rectangle)", - "minimum": 1, - }, - "x": { - "type": "number", - "description": "X coordinate of the top-left corner in mm (default: 0). Board extends from x to x+width.", - }, - "y": { - "type": "number", - "description": "Y coordinate of the top-left corner in mm (default: 0). Board extends from y to y+height.", - }, - "radius": { - "type": "number", - "description": "Corner radius in mm for rounded_rectangle, or radius for circle", - "minimum": 0, - }, - "points": { - "type": "array", - "description": "Array of {x, y} point objects in mm (for polygon shape only)", - "items": { - "type": "object", - "properties": { - "x": {"type": "number"}, - "y": {"type": "number"}, - }, - "required": ["x", "y"], - }, - "minItems": 3, - }, - }, - "required": ["shape"], - }, - }, - { - "name": "add_layer", - "title": "Add Custom Layer", - "description": "Adds a new custom layer to the board stack (e.g., User.1, User.Comments).", - "inputSchema": { - "type": "object", - "properties": { - "layerName": { - "type": "string", - "description": "Name of the layer to add", - }, - "layerType": { - "type": "string", - "enum": ["signal", "power", "mixed", "jumper"], - "description": "Type of layer (for copper layers)", - }, - }, - "required": ["layerName"], - }, - }, - { - "name": "set_active_layer", - "title": "Set Active Layer", - "description": "Sets the currently active layer for drawing operations.", - "inputSchema": { - "type": "object", - "properties": { - "layerName": { - "type": "string", - "description": "Name of the layer to make active (e.g., F.Cu, B.Cu, Edge.Cuts)", - } - }, - "required": ["layerName"], - }, - }, - { - "name": "get_layer_list", - "title": "List Board Layers", - "description": "Returns a list of all layers in the board with their properties.", - "inputSchema": {"type": "object", "properties": {}}, - }, - { - "name": "get_board_info", - "title": "Get Board Information", - "description": "Retrieves comprehensive board information including dimensions, layer count, component count, and design rules.", - "inputSchema": {"type": "object", "properties": {}}, - }, - { - "name": "get_board_2d_view", - "title": "Render Board Preview", - "description": "Generates a 2D visual representation of the current board state as a PNG image.", - "inputSchema": { - "type": "object", - "properties": { - "width": { - "type": "number", - "description": "Image width in pixels (default: 800)", - "minimum": 100, - "default": 800, - }, - "height": { - "type": "number", - "description": "Image height in pixels (default: 600)", - "minimum": 100, - "default": 600, - }, - }, - }, - }, - { - "name": "get_board_extents", - "title": "Get Board Bounding Box", - "description": "Returns the bounding box extents of the PCB board including all edge cuts, components, and traces.", - "inputSchema": { - "type": "object", - "properties": { - "unit": { - "type": "string", - "enum": ["mm", "inch"], - "description": "Unit for returned coordinates (default: mm)", - "default": "mm", - } - }, - }, - }, - { - "name": "add_mounting_hole", - "title": "Add Mounting Hole", - "description": "Adds a mounting hole (non-plated through hole) at the specified position with given diameter.", - "inputSchema": { - "type": "object", - "properties": { - "x": {"type": "number", "description": "X coordinate in millimeters"}, - "y": {"type": "number", "description": "Y coordinate in millimeters"}, - "diameter": { - "type": "number", - "description": "Hole diameter in millimeters", - "minimum": 0.1, - }, - }, - "required": ["x", "y", "diameter"], - }, - }, - { - "name": "import_svg_logo", - "title": "Import SVG Logo to PCB", - "description": "Imports an SVG file as filled graphic polygons onto a KiCAD PCB layer (default F.SilkS). Curves are linearised automatically. Supports path, rect, circle, ellipse, polygon and group transforms.", - "inputSchema": { - "type": "object", - "properties": { - "pcbPath": { - "type": "string", - "description": "Path to the .kicad_pcb file", - }, - "svgPath": { - "type": "string", - "description": "Path to the SVG logo file", - }, - "x": { - "type": "number", - "description": "X position of the logo top-left corner in mm", - }, - "y": { - "type": "number", - "description": "Y position of the logo top-left corner in mm", - }, - "width": { - "type": "number", - "description": "Target width of the logo in mm (height scaled to preserve aspect ratio)", - "minimum": 0.1, - }, - "layer": { - "type": "string", - "description": "PCB layer name, e.g. F.SilkS or B.SilkS (default: F.SilkS)", - "default": "F.SilkS", - }, - "strokeWidth": { - "type": "number", - "description": "Outline stroke width in mm (0 = no outline, default 0)", - "default": 0, - }, - "filled": { - "type": "boolean", - "description": "Fill polygons with solid layer colour (default true)", - "default": True, - }, - }, - "required": ["pcbPath", "svgPath", "x", "y", "width"], - }, - }, - { - "name": "add_board_text", - "title": "Add Text to Board", - "description": "Adds text annotation to the board on a specified layer (e.g., F.SilkS for top silkscreen).", - "inputSchema": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "Text content to add", - "minLength": 1, - }, - "x": {"type": "number", "description": "X coordinate in millimeters"}, - "y": {"type": "number", "description": "Y coordinate in millimeters"}, - "layer": { - "type": "string", - "description": "Layer name (e.g., F.SilkS, B.SilkS, F.Cu)", - "default": "F.SilkS", - }, - "size": { - "type": "number", - "description": "Text size in millimeters", - "minimum": 0.1, - "default": 1.0, - }, - "thickness": { - "type": "number", - "description": "Text thickness in millimeters", - "minimum": 0.01, - "default": 0.15, - }, - }, - "required": ["text", "x", "y"], - }, - }, -] - -# ============================================================================= -# COMPONENT TOOLS -# ============================================================================= - -COMPONENT_TOOLS = [ - { - "name": "place_component", - "title": "Place Component", - "description": "Places a component with specified footprint at given coordinates on the board.", - "inputSchema": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Component reference designator (e.g., R1, C2, U3)", - }, - "footprint": { - "type": "string", - "description": "Footprint library:name (e.g., Resistor_SMD:R_0805_2012Metric)", - }, - "x": {"type": "number", "description": "X coordinate in millimeters"}, - "y": {"type": "number", "description": "Y coordinate in millimeters"}, - "rotation": { - "type": "number", - "description": "Rotation angle in degrees (0-360)", - "minimum": 0, - "maximum": 360, - "default": 0, - }, - "layer": { - "type": "string", - "enum": ["F.Cu", "B.Cu"], - "description": "Board layer (top or bottom)", - "default": "F.Cu", - }, - }, - "required": ["reference", "footprint", "x", "y"], - }, - }, - { - "name": "move_component", - "title": "Move Component", - "description": "Moves an existing component to a new position on the board.", - "inputSchema": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Component reference designator", - }, - "x": { - "type": "number", - "description": "New X coordinate in millimeters", - }, - "y": { - "type": "number", - "description": "New Y coordinate in millimeters", - }, - }, - "required": ["reference", "x", "y"], - }, - }, - { - "name": "rotate_component", - "title": "Rotate Component", - "description": "Rotates a component by specified angle. Rotation is cumulative with existing rotation.", - "inputSchema": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Component reference designator", - }, - "angle": { - "type": "number", - "description": "Rotation angle in degrees (positive = counterclockwise)", - }, - }, - "required": ["reference", "angle"], - }, - }, - { - "name": "delete_component", - "title": "Delete Component", - "description": "Removes a component from the board.", - "inputSchema": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Component reference designator", - } - }, - "required": ["reference"], - }, - }, - { - "name": "edit_component", - "title": "Edit Component Properties", - "description": "Modifies properties of an existing component (value, footprint, etc.).", - "inputSchema": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Component reference designator", - }, - "value": {"type": "string", "description": "New component value"}, - "footprint": { - "type": "string", - "description": "New footprint library:name", - }, - }, - "required": ["reference"], - }, - }, - { - "name": "get_component_properties", - "title": "Get Component Properties", - "description": "Retrieves detailed properties of a specific component.", - "inputSchema": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Component reference designator", - } - }, - "required": ["reference"], - }, - }, - { - "name": "get_component_list", - "title": "List All Components", - "description": "Returns a list of all components on the board with their properties.", - "inputSchema": {"type": "object", "properties": {}}, - }, - { - "name": "find_component", - "title": "Find Components", - "description": "Searches for components matching specified criteria. Supports partial matching on reference, value, or footprint patterns.", - "inputSchema": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Reference designator pattern to match (e.g., 'R1', 'U', 'C2')", - }, - "value": { - "type": "string", - "description": "Value pattern to match (e.g., '10k', '100nF')", - }, - "footprint": { - "type": "string", - "description": "Footprint pattern to match (e.g., '0805', 'SOIC')", - }, - }, - }, - }, - { - "name": "get_component_pads", - "title": "Get Component Pads", - "description": "Returns all pads for a component with their positions, net connections, sizes, and shapes.", - "inputSchema": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Component reference designator (e.g., U1, R5)", - } - }, - "required": ["reference"], - }, - }, - { - "name": "get_pad_position", - "title": "Get Pad Position", - "description": "Returns the position and properties of a specific pad on a component.", - "inputSchema": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Component reference designator", - }, - "padName": { - "type": "string", - "description": "Pad name or number (e.g., '1', '2', 'A1')", - }, - "padNumber": { - "type": "string", - "description": "Alternative to padName - pad number", - }, - }, - "required": ["reference"], - }, - }, - { - "name": "place_component_array", - "title": "Place Component Array", - "description": "Places multiple copies of a component in a grid or circular pattern.", - "inputSchema": { - "type": "object", - "properties": { - "referencePrefix": { - "type": "string", - "description": "Reference prefix (e.g., 'R' for R1, R2, R3...)", - }, - "startNumber": { - "type": "integer", - "description": "Starting number for references", - "minimum": 1, - "default": 1, - }, - "footprint": { - "type": "string", - "description": "Footprint library:name", - }, - "pattern": { - "type": "string", - "enum": ["grid", "circular"], - "description": "Array pattern type", - }, - "count": { - "type": "integer", - "description": "Total number of components to place", - "minimum": 1, - }, - "startX": { - "type": "number", - "description": "Starting X coordinate in millimeters", - }, - "startY": { - "type": "number", - "description": "Starting Y coordinate in millimeters", - }, - "spacingX": { - "type": "number", - "description": "Horizontal spacing in mm (for grid pattern)", - }, - "spacingY": { - "type": "number", - "description": "Vertical spacing in mm (for grid pattern)", - }, - "radius": { - "type": "number", - "description": "Circle radius in mm (for circular pattern)", - }, - "rows": { - "type": "integer", - "description": "Number of rows (for grid pattern)", - "minimum": 1, - }, - "columns": { - "type": "integer", - "description": "Number of columns (for grid pattern)", - "minimum": 1, - }, - }, - "required": [ - "referencePrefix", - "footprint", - "pattern", - "count", - "startX", - "startY", - ], - }, - }, - { - "name": "align_components", - "title": "Align Components", - "description": "Aligns multiple components horizontally or vertically.", - "inputSchema": { - "type": "object", - "properties": { - "references": { - "type": "array", - "description": "Array of component reference designators to align", - "items": {"type": "string"}, - "minItems": 2, - }, - "direction": { - "type": "string", - "enum": ["horizontal", "vertical"], - "description": "Alignment direction", - }, - "spacing": { - "type": "number", - "description": "Spacing between components in mm (optional, for even distribution)", - }, - }, - "required": ["references", "direction"], - }, - }, - { - "name": "duplicate_component", - "title": "Duplicate Component", - "description": "Creates a copy of an existing component with new reference designator.", - "inputSchema": { - "type": "object", - "properties": { - "sourceReference": { - "type": "string", - "description": "Reference of component to duplicate", - }, - "newReference": { - "type": "string", - "description": "Reference designator for the new component", - }, - "offsetX": { - "type": "number", - "description": "X offset from original position in mm", - "default": 0, - }, - "offsetY": { - "type": "number", - "description": "Y offset from original position in mm", - "default": 0, - }, - }, - "required": ["sourceReference", "newReference"], - }, - }, -] - -# ============================================================================= -# ROUTING TOOLS -# ============================================================================= - -ROUTING_TOOLS = [ - { - "name": "add_net", - "title": "Create Electrical Net", - "description": "Creates a new electrical net for signal routing.", - "inputSchema": { - "type": "object", - "properties": { - "netName": { - "type": "string", - "description": "Name of the net (e.g., VCC, GND, SDA)", - "minLength": 1, - }, - "netClass": { - "type": "string", - "description": "Optional net class to assign (must exist first)", - }, - }, - "required": ["netName"], - }, - }, - { - "name": "route_trace", - "title": "Route PCB Trace", - "description": "Routes a copper trace between two points or pads on a specified layer.", - "inputSchema": { - "type": "object", - "properties": { - "netName": {"type": "string", "description": "Net name for this trace"}, - "layer": { - "type": "string", - "description": "Layer to route on (e.g., F.Cu, B.Cu)", - "default": "F.Cu", - }, - "width": { - "type": "number", - "description": "Trace width in millimeters", - "minimum": 0.1, - }, - "points": { - "type": "array", - "description": "Array of [x, y] waypoints in millimeters", - "items": { - "type": "array", - "items": {"type": "number"}, - "minItems": 2, - "maxItems": 2, - }, - "minItems": 2, - }, - }, - "required": ["points", "width"], - }, - }, - { - "name": "add_via", - "title": "Add Via", - "description": "Adds a via (plated through-hole) to connect traces between layers.", - "inputSchema": { - "type": "object", - "properties": { - "x": {"type": "number", "description": "X coordinate in millimeters"}, - "y": {"type": "number", "description": "Y coordinate in millimeters"}, - "diameter": { - "type": "number", - "description": "Via diameter in millimeters", - "minimum": 0.1, - }, - "drill": { - "type": "number", - "description": "Drill diameter in millimeters", - "minimum": 0.1, - }, - "netName": { - "type": "string", - "description": "Net name to assign to this via", - }, - }, - "required": ["x", "y", "diameter", "drill"], - }, - }, - { - "name": "delete_trace", - "title": "Delete Trace", - "description": "Removes traces from the board. Can delete by UUID, position, or bulk-delete all traces on a net.", - "inputSchema": { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "UUID of a specific trace to delete", - }, - "position": { - "type": "object", - "description": "Delete trace nearest to this position", - "properties": { - "x": {"type": "number", "description": "X coordinate"}, - "y": {"type": "number", "description": "Y coordinate"}, - "unit": { - "type": "string", - "enum": ["mm", "inch"], - "default": "mm", - }, - }, - "required": ["x", "y"], - }, - "net": { - "type": "string", - "description": "Delete all traces on this net (bulk delete)", - }, - "layer": { - "type": "string", - "description": "Filter by layer when using net-based deletion", - }, - "includeVias": { - "type": "boolean", - "description": "Include vias in net-based deletion", - "default": False, - }, - }, - }, - }, - { - "name": "query_traces", - "title": "Query Traces", - "description": "Queries traces on the board with optional filters by net, layer, or bounding box. Returns trace details including UUID, positions, width, and length.", - "inputSchema": { - "type": "object", - "properties": { - "net": { - "type": "string", - "description": "Filter by net name (e.g., 'GND', 'VCC')", - }, - "layer": { - "type": "string", - "description": "Filter by layer name (e.g., 'F.Cu', 'B.Cu')", - }, - "boundingBox": { - "type": "object", - "description": "Filter by bounding box region", - "properties": { - "x1": {"type": "number", "description": "Left X coordinate"}, - "y1": {"type": "number", "description": "Top Y coordinate"}, - "x2": {"type": "number", "description": "Right X coordinate"}, - "y2": {"type": "number", "description": "Bottom Y coordinate"}, - "unit": { - "type": "string", - "enum": ["mm", "inch"], - "default": "mm", - }, - }, - }, - "includeVias": { - "type": "boolean", - "description": "Include vias in the result", - "default": False, - }, - }, - }, - }, - { - "name": "modify_trace", - "title": "Modify Trace", - "description": "Modifies properties of an existing trace. Find trace by UUID or position, then change width, layer, or net assignment.", - "inputSchema": { - "type": "object", - "properties": { - "uuid": { - "type": "string", - "description": "UUID of the trace to modify", - }, - "position": { - "type": "object", - "description": "Find trace nearest to this position", - "properties": { - "x": {"type": "number", "description": "X coordinate"}, - "y": {"type": "number", "description": "Y coordinate"}, - "unit": { - "type": "string", - "enum": ["mm", "inch"], - "default": "mm", - }, - }, - "required": ["x", "y"], - }, - "width": {"type": "number", "description": "New trace width in mm"}, - "layer": { - "type": "string", - "description": "New layer name (e.g., 'F.Cu', 'B.Cu')", - }, - "net": {"type": "string", "description": "New net name to assign"}, - }, - }, - }, - { - "name": "copy_routing_pattern", - "title": "Copy Routing Pattern", - "description": "Copies routing pattern from source components to target components. Enables routing replication between identical component groups by calculating and applying position offset.", - "inputSchema": { - "type": "object", - "properties": { - "sourceRefs": { - "type": "array", - "items": {"type": "string"}, - "description": "Source component references (e.g., ['U1', 'U2', 'U3'])", - }, - "targetRefs": { - "type": "array", - "items": {"type": "string"}, - "description": "Target component references (e.g., ['U4', 'U5', 'U6'])", - }, - "includeVias": { - "type": "boolean", - "description": "Include vias in the pattern copy", - "default": True, - }, - "traceWidth": { - "type": "number", - "description": "Override trace width in mm (uses original if not specified)", - }, - }, - "required": ["sourceRefs", "targetRefs"], - }, - }, - { - "name": "get_nets_list", - "title": "List All Nets", - "description": "Returns a list of all electrical nets defined on the board.", - "inputSchema": {"type": "object", "properties": {}}, - }, - { - "name": "create_netclass", - "title": "Create Net Class", - "description": "Defines a net class with specific routing rules (trace width, clearance, etc.).", - "inputSchema": { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "Net class name", - "minLength": 1, - }, - "traceWidth": { - "type": "number", - "description": "Default trace width in millimeters", - "minimum": 0.1, - }, - "clearance": { - "type": "number", - "description": "Clearance in millimeters", - "minimum": 0.1, - }, - "viaDiameter": { - "type": "number", - "description": "Via diameter in millimeters", - }, - "viaDrill": { - "type": "number", - "description": "Via drill diameter in millimeters", - }, - }, - "required": ["name", "traceWidth", "clearance"], - }, - }, - { - "name": "add_copper_pour", - "title": "Add Copper Pour", - "description": "Creates a copper pour/zone (typically for ground or power planes).", - "inputSchema": { - "type": "object", - "properties": { - "netName": { - "type": "string", - "description": "Net to connect this copper pour to (e.g., GND, VCC)", - }, - "layer": { - "type": "string", - "description": "Layer for the copper pour (e.g., F.Cu, B.Cu)", - }, - "priority": { - "type": "integer", - "description": "Pour priority (higher priorities fill first)", - "minimum": 0, - "default": 0, - }, - "clearance": { - "type": "number", - "description": "Clearance from other objects in millimeters", - "minimum": 0.1, - }, - "outline": { - "type": "array", - "description": "Array of [x, y] points defining the pour boundary", - "items": { - "type": "array", - "items": {"type": "number"}, - "minItems": 2, - "maxItems": 2, - }, - "minItems": 3, - }, - }, - "required": ["netName", "layer", "outline"], - }, - }, - { - "name": "route_differential_pair", - "title": "Route Differential Pair", - "description": "Routes a differential signal pair with matched lengths and spacing.", - "inputSchema": { - "type": "object", - "properties": { - "positiveName": { - "type": "string", - "description": "Positive signal net name", - }, - "negativeName": { - "type": "string", - "description": "Negative signal net name", - }, - "layer": {"type": "string", "description": "Layer to route on"}, - "width": { - "type": "number", - "description": "Trace width in millimeters", - }, - "gap": { - "type": "number", - "description": "Gap between traces in millimeters", - }, - "points": { - "type": "array", - "description": "Waypoints for the pair routing", - "items": { - "type": "array", - "items": {"type": "number"}, - "minItems": 2, - "maxItems": 2, - }, - "minItems": 2, - }, - }, - "required": ["positiveName", "negativeName", "width", "gap", "points"], - }, - }, -] - -# ============================================================================= -# LIBRARY TOOLS -# ============================================================================= - -LIBRARY_TOOLS = [ - { - "name": "list_libraries", - "title": "List Footprint Libraries", - "description": "Lists all available footprint libraries accessible to KiCAD.", - "inputSchema": {"type": "object", "properties": {}}, - }, - { - "name": "search_footprints", - "title": "Search Footprints", - "description": "Searches for footprints matching a query string across all libraries.", - "inputSchema": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search query (e.g., '0805', 'SOIC', 'QFP')", - "minLength": 1, - }, - "library": { - "type": "string", - "description": "Optional library to restrict search to", - }, - }, - "required": ["query"], - }, - }, - { - "name": "list_library_footprints", - "title": "List Footprints in Library", - "description": "Lists all footprints available in a specific library.", - "inputSchema": { - "type": "object", - "properties": { - "library": { - "type": "string", - "description": "Library name (e.g., Resistor_SMD, Connector_PinHeader)", - "minLength": 1, - } - }, - "required": ["library"], - }, - }, - { - "name": "get_footprint_info", - "title": "Get Footprint Details", - "description": "Retrieves detailed information about a specific footprint including pad layout, dimensions, and description.", - "inputSchema": { - "type": "object", - "properties": { - "library": {"type": "string", "description": "Library name"}, - "footprint": {"type": "string", "description": "Footprint name"}, - }, - "required": ["library", "footprint"], - }, - }, -] - -# ============================================================================= -# DESIGN RULE TOOLS -# ============================================================================= - -DESIGN_RULE_TOOLS = [ - { - "name": "set_design_rules", - "title": "Set Design Rules", - "description": "Configures board design rules including clearances, trace widths, and via sizes.", - "inputSchema": { - "type": "object", - "properties": { - "clearance": { - "type": "number", - "description": "Minimum clearance between copper in millimeters", - "minimum": 0.1, - }, - "trackWidth": { - "type": "number", - "description": "Minimum track width in millimeters", - "minimum": 0.1, - }, - "viaDiameter": { - "type": "number", - "description": "Minimum via diameter in millimeters", - }, - "viaDrill": { - "type": "number", - "description": "Minimum via drill diameter in millimeters", - }, - "microViaD iameter": { - "type": "number", - "description": "Minimum micro-via diameter in millimeters", - }, - }, - }, - }, - { - "name": "get_design_rules", - "title": "Get Current Design Rules", - "description": "Retrieves the currently configured design rules from the board.", - "inputSchema": {"type": "object", "properties": {}}, - }, - { - "name": "run_drc", - "title": "Run Design Rule Check", - "description": "Executes a design rule check (DRC) on the current board and reports violations.", - "inputSchema": { - "type": "object", - "properties": { - "includeWarnings": { - "type": "boolean", - "description": "Include warnings in addition to errors", - "default": True, - } - }, - }, - }, - { - "name": "get_drc_violations", - "title": "Get DRC Violations", - "description": "Returns a list of design rule violations from the most recent DRC run.", - "inputSchema": {"type": "object", "properties": {}}, - }, -] - -# ============================================================================= -# EXPORT TOOLS -# ============================================================================= - -EXPORT_TOOLS = [ - { - "name": "export_gerber", - "title": "Export Gerber Files", - "description": "Generates Gerber files for PCB fabrication (industry standard format).", - "inputSchema": { - "type": "object", - "properties": { - "outputPath": { - "type": "string", - "description": "Directory path for output files", - }, - "layers": { - "type": "array", - "description": "List of layers to export (if not provided, exports all copper and mask layers)", - "items": {"type": "string"}, - }, - "includeDrillFiles": { - "type": "boolean", - "description": "Include drill files (Excellon format)", - "default": True, - }, - }, - "required": ["outputPath"], - }, - }, - { - "name": "export_pdf", - "title": "Export PDF", - "description": "Exports the board layout as a PDF document for documentation or review.", - "inputSchema": { - "type": "object", - "properties": { - "outputPath": { - "type": "string", - "description": "Path for output PDF file", - }, - "layers": { - "type": "array", - "description": "Layers to include in PDF", - "items": {"type": "string"}, - }, - "colorMode": { - "type": "string", - "enum": ["color", "black_white"], - "description": "Color mode for output", - "default": "color", - }, - }, - "required": ["outputPath"], - }, - }, - { - "name": "export_svg", - "title": "Export SVG", - "description": "Exports the board as Scalable Vector Graphics for documentation or web display.", - "inputSchema": { - "type": "object", - "properties": { - "outputPath": { - "type": "string", - "description": "Path for output SVG file", - }, - "layers": { - "type": "array", - "description": "Layers to include in SVG", - "items": {"type": "string"}, - }, - }, - "required": ["outputPath"], - }, - }, - { - "name": "export_3d", - "title": "Export 3D Model", - "description": "Exports a 3D model of the board in STEP or VRML format for mechanical CAD integration.", - "inputSchema": { - "type": "object", - "properties": { - "outputPath": { - "type": "string", - "description": "Path for output 3D file", - }, - "format": { - "type": "string", - "enum": ["step", "vrml"], - "description": "3D model format", - "default": "step", - }, - "includeComponents": { - "type": "boolean", - "description": "Include 3D component models", - "default": True, - }, - }, - "required": ["outputPath"], - }, - }, - { - "name": "export_bom", - "title": "Export Bill of Materials", - "description": "Generates a bill of materials (BOM) listing all components with references, values, and footprints.", - "inputSchema": { - "type": "object", - "properties": { - "outputPath": { - "type": "string", - "description": "Path for output BOM file", - }, - "format": { - "type": "string", - "enum": ["csv", "xml", "html"], - "description": "BOM output format", - "default": "csv", - }, - "groupByValue": { - "type": "boolean", - "description": "Group components with same value together", - "default": True, - }, - }, - "required": ["outputPath"], - }, - }, -] - -# ============================================================================= -# SCHEMATIC TOOLS -# ============================================================================= - -SCHEMATIC_TOOLS = [ - { - "name": "create_schematic", - "title": "Create New Schematic", - "description": "Creates a new KiCAD schematic file for circuit design.", - "inputSchema": { - "type": "object", - "properties": { - "filename": { - "type": "string", - "description": "Path for the new schematic file (.kicad_sch)", - }, - "title": {"type": "string", "description": "Schematic title"}, - }, - "required": ["filename"], - }, - }, - { - "name": "load_schematic", - "title": "Load Existing Schematic", - "description": "Opens an existing KiCAD schematic file for editing.", - "inputSchema": { - "type": "object", - "properties": { - "filename": { - "type": "string", - "description": "Path to schematic file (.kicad_sch)", - } - }, - "required": ["filename"], - }, - }, - { - "name": "add_schematic_component", - "title": "Add Component to Schematic", - "description": "Places a symbol (resistor, capacitor, IC, etc.) on the schematic.", - "inputSchema": { - "type": "object", - "properties": { - "reference": { - "type": "string", - "description": "Reference designator (e.g., R1, C2, U3)", - }, - "symbol": { - "type": "string", - "description": "Symbol library:name (e.g., Device:R, Device:C)", - }, - "value": { - "type": "string", - "description": "Component value (e.g., 10k, 0.1uF)", - }, - "x": {"type": "number", "description": "X coordinate on schematic"}, - "y": {"type": "number", "description": "Y coordinate on schematic"}, - }, - "required": ["reference", "symbol", "x", "y"], - }, - }, - { - "name": "add_schematic_wire", - "title": "Draw Wire Between Pins", - "description": "Draws a wire on the schematic between two or more coordinate points. Always call get_schematic_pin_locations first to get the approximate pin coordinates, then pass them as the first and last waypoints. snapToPins (on by default) will correct any float imprecision by snapping endpoints to the exact nearest pin coordinate. To route around components, add intermediate waypoints between the start and end: e.g. [[x1,y1], [xMid,y1], [xMid,y2], [x2,y2]] routes horizontally then vertically. Intermediate waypoints are never snapped.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to schematic file", - }, - "waypoints": { - "type": "array", - "description": "Array of [x, y] coordinates defining the wire path. First and last points are the pin locations (from get_schematic_pin_locations). Add intermediate points to route around obstacles.", - "items": { - "type": "array", - "items": {"type": "number"}, - "minItems": 2, - "maxItems": 2, - }, - "minItems": 2, - }, - "snapToPins": { - "type": "boolean", - "description": "When true, the first and last waypoints are snapped to the nearest schematic pin within snapTolerance mm. Intermediate waypoints are left unchanged. Enabled by default to correct float coordinate imprecision.", - "default": True, - }, - "snapTolerance": { - "type": "number", - "description": "Maximum distance in mm to search for a nearby pin when snapToPins is enabled.", - "default": 1.0, - }, - }, - "required": ["schematicPath", "waypoints"], - }, - }, - { - "name": "add_schematic_net_label", - "title": "Add Net Label", - "description": ( - "Add a net label to a schematic. " - "PREFERRED: supply componentRef + pinNumber to snap the label to the exact pin endpoint — " - "this guarantees an electrical connection. " - "Alternatively supply position [x, y], but the coordinates must match the pin endpoint exactly " - "(even a 0.01 mm offset breaks the connection). " - "The response includes actual_position (coordinates actually used) and snapped_to_pin " - "(present when a pin reference was resolved)." - ), - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to schematic file", - }, - "netName": { - "type": "string", - "description": "Name of the net (e.g., VCC, GND, SDA)", - }, - "position": { - "type": "array", - "items": {"type": "number"}, - "minItems": 2, - "maxItems": 2, - "description": "Position [x, y] for the label. Required when componentRef/pinNumber are not given.", - }, - "componentRef": { - "type": "string", - "description": "Component reference to snap label to (e.g. U1, R1). Use with pinNumber.", - }, - "pinNumber": { - "type": "string", - "description": "Pin number or name on componentRef (e.g. '1', 'GND'). Use with componentRef.", - }, - "labelType": { - "type": "string", - "enum": ["label", "global_label", "hierarchical_label"], - "description": "Label type (default: label)", - "default": "label", - }, - "orientation": { - "type": "number", - "description": "Rotation angle in degrees (0, 90, 180, 270)", - "default": 0, - }, - }, - "required": ["schematicPath", "netName"], - }, - }, - { - "name": "connect_to_net", - "title": "Connect Pin to Net", - "description": ( - "Connect a component pin to a named net by adding a wire stub and net label at the exact " - "pin endpoint. The response includes pin_location (exact pin coords), label_location " - "(where the label was placed), and wire_stub (the wire segment added) so you can confirm " - "the placement without a separate verification call." - ), - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to schematic file", - }, - "componentRef": { - "type": "string", - "description": "Component reference designator (e.g., R1, U3)", - }, - "pinName": { - "type": "string", - "description": "Pin number or name on the component", - }, - "netName": { - "type": "string", - "description": "Name of the net to connect to", - }, - }, - "required": ["schematicPath", "componentRef", "pinName", "netName"], - }, - }, - { - "name": "get_net_connections", - "title": "Get Net Connections", - "description": "Returns all components and pins connected to a specified net.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to schematic file", - }, - "netName": { - "type": "string", - "description": "Name of the net to query", - }, - }, - "required": ["schematicPath", "netName"], - }, - }, - { - "name": "get_wire_connections", - "title": "Get Wire Connections", - "description": ( - "Returns the net name and all wires and component pins connected at a given point. " - "Accepts either a component reference + pin number (e.g. reference='U1', pin='3') " - "or a schematic coordinate (x, y in mm). " - "The response includes: 'net' (label name or null for unnamed nets), " - "'pins' (all component pins on the net), 'wires' (all wire segments on the net), " - "and 'query_point' (the resolved coordinate used). " - "The query point must be at a wire endpoint or junction — wire midpoints are not matched. " - "Use get_schematic_pin_locations or list_schematic_wires to obtain exact endpoint coordinates." - ), - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the schematic file (.kicad_sch)", - }, - "reference": { - "type": "string", - "description": "Component reference (e.g. U1, R1). Pair with pin.", - }, - "pin": { - "type": "string", - "description": "Pin number or name (e.g. '3', 'SDA'). Pair with reference.", - }, - "x": { - "type": "number", - "description": "X coordinate of a wire endpoint in mm. Pair with y.", - }, - "y": { - "type": "number", - "description": "Y coordinate of a wire endpoint in mm. Pair with x.", - }, - }, - "required": ["schematicPath"], - }, - }, - { - "name": "get_net_at_point", - "title": "Get Net At Point", - "description": ( - "Returns the net name at a given (x, y) coordinate in a schematic, " - "or null if no net label or wire endpoint is present at that position. " - "Checks net label positions first, then wire endpoints. " - "Useful for quickly identifying what net occupies a specific coordinate " - "without traversing the full wire graph." - ), - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the schematic file (.kicad_sch)", - }, - "x": { - "type": "number", - "description": "X coordinate in mm", - }, - "y": { - "type": "number", - "description": "Y coordinate in mm", - }, - }, - "required": ["schematicPath", "x", "y"], - }, - }, - { - "name": "get_schematic_pin_locations", - "title": "Get Schematic Pin Locations", - "description": "Returns the exact absolute coordinates of all pins on a schematic component. Use this BEFORE placing net labels with add_schematic_net_label to get the correct x/y position for each pin endpoint.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the schematic file", - }, - "reference": { - "type": "string", - "description": "Component reference designator (e.g., U1, R1, J2)", - }, - }, - "required": ["schematicPath", "reference"], - }, - }, - { - "name": "connect_passthrough", - "title": "Connect Passthrough (Pin-to-Pin)", - "description": "Connects all pins of a source connector to the matching pins of a target connector using shared net labels. Ideal for passthrough adapters where J1 pin N connects directly to J2 pin N. Each pair gets a net label '{netPrefix}_{pinNumber}'. Use this instead of calling connect_to_net 15 times for FFC/ribbon cable passthroughs. NOTE: KiCAD Connector_Generic symbols always have pin 1 at the TOP of the symbol and pin N at the BOTTOM. When assigning named nets (e.g. GND, CAM_SCL) to specific pin numbers, always use the physical pin number as shown in the connector datasheet — pin 1 = top of symbol.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the schematic file", - }, - "sourceRef": { - "type": "string", - "description": "Reference of the source connector (e.g., J1)", - }, - "targetRef": { - "type": "string", - "description": "Reference of the target connector (e.g., J2)", - }, - "netPrefix": { - "type": "string", - "description": "Prefix for generated net names, e.g. 'CSI' produces CSI_1, CSI_2, ... (default: PIN)", - }, - "pinOffset": { - "type": "integer", - "description": "Add this value to the pin number when building net names (default: 0)", - }, - }, - "required": ["schematicPath", "sourceRef", "targetRef"], - }, - }, - { - "name": "run_erc", - "title": "Run Electrical Rules Check (ERC)", - "description": "Runs the KiCAD Electrical Rules Check (ERC) on a schematic via kicad-cli and returns all violations with type, severity, and location. Use this to verify the schematic is electrically correct before generating a netlist or exporting.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the .kicad_sch schematic file", - } - }, - "required": ["schematicPath"], - }, - }, - { - "name": "sync_schematic_to_board", - "title": "Sync Schematic to PCB (F8)", - "description": "Reads net connections from the schematic and assigns them to matching component pads in the PCB board file. Equivalent to KiCAD Pcbnew F8 'Update PCB from Schematic'. Must be called after placing components and before routing traces, so that pad-to-net assignments are correct.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to .kicad_sch file. If omitted, auto-detected from current board path.", - }, - "boardPath": { - "type": "string", - "description": "Path to .kicad_pcb file. If omitted, uses currently loaded board.", - }, - }, - }, - }, - { - "name": "generate_netlist", - "title": "Generate Netlist (JSON)", - "description": ( - "Returns a structured JSON netlist from the schematic: component list " - "(reference, value, footprint) and net list (net name + all connected " - "component/pin pairs). Uses kicad-cli internally — requires a saved " - ".kicad_sch file. For writing to a file or exporting SPICE/Cadstar/OrcadPCB2 " - "format, use export_netlist instead." - ), - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Absolute path to the .kicad_sch schematic file", - }, - }, - "required": ["schematicPath"], - }, - }, - { - "name": "list_schematic_libraries", - "title": "List Symbol Libraries", - "description": "Lists all available symbol libraries for schematic design.", - "inputSchema": { - "type": "object", - "properties": { - "searchPaths": { - "type": "array", - "description": "Optional additional paths to search for libraries", - "items": {"type": "string"}, - } - }, - }, - }, - { - "name": "export_schematic_pdf", - "title": "Export Schematic to PDF", - "description": "Exports the schematic as a PDF document for printing or documentation.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to schematic file", - }, - "outputPath": {"type": "string", "description": "Path for output PDF"}, - }, - "required": ["schematicPath", "outputPath"], - }, - }, - { - "name": "add_schematic_junction", - "title": "Add Junction to Schematic", - "description": "Adds a junction (connection dot) at the specified coordinates on the schematic. Junctions are required in KiCAD to mark intentional connections where wires cross or where a wire branches off another wire. Without a junction, crossing wires are not electrically connected.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to schematic file", - }, - "position": { - "type": "array", - "description": "The [x, y] coordinates where the junction should be placed. Must be on an existing wire intersection or branch point.", - "items": {"type": "number"}, - "minItems": 2, - "maxItems": 2, - }, - }, - "required": ["schematicPath", "position"], - }, - }, - # --- Schematic Analysis Tools (read-only) --- - { - "name": "get_schematic_view_region", - "title": "Get Schematic View Region", - "description": "Exports a cropped region of the schematic as an image (PNG or SVG). Specify a bounding box in schematic mm coordinates to zoom into a specific area.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the .kicad_sch schematic file", - }, - "x1": { - "type": "number", - "description": "Left X coordinate of the region in mm", - }, - "y1": { - "type": "number", - "description": "Top Y coordinate of the region in mm", - }, - "x2": { - "type": "number", - "description": "Right X coordinate of the region in mm", - }, - "y2": { - "type": "number", - "description": "Bottom Y coordinate of the region in mm", - }, - "format": { - "type": "string", - "enum": ["png", "svg"], - "description": "Output image format (default: png)", - }, - "width": { - "type": "integer", - "description": "Output image width in pixels (default: 800)", - }, - "height": { - "type": "integer", - "description": "Output image height in pixels (default: 600)", - }, - }, - "required": ["schematicPath", "x1", "y1", "x2", "y2"], - }, - }, - { - "name": "find_overlapping_elements", - "title": "Find Overlapping Elements", - "description": "Detects spatially overlapping symbols, wires, and labels in the schematic. Finds: duplicate power symbols at the same position, collinear overlapping wire segments, and labels stacked on top of each other.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the .kicad_sch schematic file", - }, - "tolerance": { - "type": "number", - "description": "Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)", - }, - }, - "required": ["schematicPath"], - }, - }, - { - "name": "get_elements_in_region", - "title": "Get Elements in Region", - "description": "Lists all symbols, wires, and labels within a rectangular region of the schematic. Useful for understanding what is in a specific area before modifying it.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the .kicad_sch schematic file", - }, - "x1": { - "type": "number", - "description": "Left X coordinate of the region in mm", - }, - "y1": { - "type": "number", - "description": "Top Y coordinate of the region in mm", - }, - "x2": { - "type": "number", - "description": "Right X coordinate of the region in mm", - }, - "y2": { - "type": "number", - "description": "Bottom Y coordinate of the region in mm", - }, - }, - "required": ["schematicPath", "x1", "y1", "x2", "y2"], - }, - }, - { - "name": "find_wires_crossing_symbols", - "title": "Find Wires Crossing Symbols", - "description": "Find all wires that cross over component symbol bodies. Wires passing over symbols are unacceptable in schematics — they indicate routing mistakes where a wire was drawn across a component instead of around it.", - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the .kicad_sch schematic file", - } - }, - "required": ["schematicPath"], - }, - }, - { - "name": "find_orphaned_wires", - "title": "Find Orphaned Wires", - "description": ( - "Find wire segments with at least one dangling endpoint — an endpoint not connected " - "to a component pin, net label, or another wire. " - "Orphaned wires cause ERC 'wire end unconnected' errors and indicate routing mistakes. " - "Does not require the KiCad UI to be running." - ), - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the .kicad_sch schematic file", - } - }, - "required": ["schematicPath"], - }, - }, - { - "name": "list_floating_labels", - "title": "List Floating Net Labels", - "description": ( - "Returns all net labels in the schematic that are not connected to any component pin. " - "A label is 'floating' when no component pin's coordinate falls on the wire-network " - "reachable from the label's anchor position. " - "Floating labels indicate misplaced or off-grid labels that will cause ERC errors. " - "Does not require the KiCad UI to be running." - ), - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the .kicad_sch schematic file", - } - }, - "required": ["schematicPath"], - }, - }, - { - "name": "snap_to_grid", - "title": "Snap Schematic Elements to Grid", - "description": ( - "Snap schematic element coordinates to the nearest grid point. " - "KiCAD eeschema uses exact integer matching (10 000 IU/mm) for connectivity, " - "so even a sub-pixel coordinate offset will make wires appear connected visually " - "but fail ERC checks. Running this tool before ERC eliminates that class of error. " - "Modifies the .kicad_sch file in place. " - "Does not require the KiCAD UI to be running." - ), - "inputSchema": { - "type": "object", - "properties": { - "schematicPath": { - "type": "string", - "description": "Path to the .kicad_sch schematic file", - }, - "gridSize": { - "type": "number", - "description": ( - "Grid spacing in mm (default: 1.27 — standard KiCAD schematic grid). " - "Do NOT use 2.54: half of all valid KiCAD pin positions are at odd " - "multiples of 1.27 mm and would be displaced 1.27 mm, breaking " - "connectivity." - ), - "default": 1.27, - }, - "elements": { - "type": "array", - "description": ( - "Element types to snap. " - 'Valid values: "wires", "junctions", "labels", "components". ' - 'Defaults to ["wires", "junctions", "labels"] when omitted. ' - '"components" is opt-in because moving a component without re-routing ' - "its wires will create new mismatches." - ), - "items": { - "type": "string", - "enum": ["wires", "junctions", "labels", "components"], - }, - }, - }, - "required": ["schematicPath"], - }, - }, -] - -# ============================================================================= -# UI/PROCESS TOOLS -# ============================================================================= - -UI_TOOLS = [ - { - "name": "check_kicad_ui", - "title": "Check KiCAD UI Status", - "description": "Checks if KiCAD user interface is currently running and returns process information.", - "inputSchema": {"type": "object", "properties": {}}, - }, - { - "name": "launch_kicad_ui", - "title": "Launch KiCAD Application", - "description": "Opens the KiCAD graphical user interface, optionally with a specific project loaded.", - "inputSchema": { - "type": "object", - "properties": { - "projectPath": { - "type": "string", - "description": "Optional path to project file to open in UI", - }, - "autoLaunch": { - "type": "boolean", - "description": "Whether to automatically launch if not running", - "default": True, - }, - }, - }, - }, -] - -# ============================================================================= -# COMBINED TOOL SCHEMAS -# ============================================================================= - -TOOL_SCHEMAS: Dict[str, Any] = {} - -# Combine all tool categories -for tool in ( - PROJECT_TOOLS - + BOARD_TOOLS - + COMPONENT_TOOLS - + ROUTING_TOOLS - + LIBRARY_TOOLS - + DESIGN_RULE_TOOLS - + EXPORT_TOOLS - + SCHEMATIC_TOOLS - + UI_TOOLS -): - TOOL_SCHEMAS[tool["name"]] = tool - -# Total: 46 tools with comprehensive schemas +""" +Comprehensive tool schema definitions for all KiCAD MCP commands + +Following MCP 2025-06-18 specification for tool definitions. +Each tool includes: +- name: Unique identifier +- title: Human-readable display name +- description: Detailed explanation of what the tool does +- inputSchema: JSON Schema for parameters +- outputSchema: Optional JSON Schema for return values (structured content) +""" + +from typing import Any, Dict + +# ============================================================================= +# PROJECT TOOLS +# ============================================================================= + +PROJECT_TOOLS = [ + { + "name": "create_project", + "title": "Create New KiCAD Project", + "description": "Creates a new KiCAD project with PCB board file and optional project configuration. Automatically creates project directory and initializes board with default settings.", + "inputSchema": { + "type": "object", + "properties": { + "projectName": { + "type": "string", + "description": "Name of the project (used for file naming)", + "minLength": 1, + }, + "path": { + "type": "string", + "description": "Directory path where project will be created (defaults to current working directory)", + }, + "template": { + "type": "string", + "description": "Optional path to template board file to copy settings from", + }, + }, + "required": ["projectName"], + }, + }, + { + "name": "open_project", + "title": "Open Existing KiCAD Project", + "description": "Opens an existing KiCAD project file (.kicad_pro or .kicad_pcb) and loads the board into memory for manipulation.", + "inputSchema": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "Path to .kicad_pro or .kicad_pcb file", + } + }, + "required": ["filename"], + }, + }, + { + "name": "save_project", + "title": "Save Current Project", + "description": "Saves the current board to disk. Can optionally save to a new location.", + "inputSchema": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "Optional new path to save the board (if not provided, saves to current location)", + } + }, + }, + }, + { + "name": "snapshot_project", + "title": "Snapshot Project (Checkpoint)", + "description": "Copies the entire project folder to a new timestamped snapshot directory so you can resume from this checkpoint later without redoing earlier steps. Call this after every successfully completed design step (e.g. after Step 1 schematic, after Step 2 PCB layout) before asking for user confirmation to proceed.", + "inputSchema": { + "type": "object", + "properties": { + "step": { + "type": "string", + "description": "Step number or name to include in snapshot folder name, e.g. '1' or '2'", + }, + "label": { + "type": "string", + "description": "Optional short label, e.g. 'schematic_ok' or 'layout_ok'", + }, + "projectPath": { + "type": "string", + "description": "Project directory path. Auto-detected from loaded board if omitted.", + }, + }, + }, + }, + { + "name": "get_project_info", + "title": "Get Project Information", + "description": "Retrieves metadata and properties of the currently open project including name, paths, and board status.", + "inputSchema": {"type": "object", "properties": {}}, + }, +] + +# ============================================================================= +# BOARD TOOLS +# ============================================================================= + +BOARD_TOOLS = [ + { + "name": "set_board_size", + "title": "Set Board Dimensions", + "description": "Sets the PCB board dimensions. The board outline must be added separately using add_board_outline.", + "inputSchema": { + "type": "object", + "properties": { + "width": { + "type": "number", + "description": "Board width in millimeters", + "minimum": 1, + }, + "height": { + "type": "number", + "description": "Board height in millimeters", + "minimum": 1, + }, + }, + "required": ["width", "height"], + }, + }, + { + "name": "add_board_outline", + "title": "Add Board Outline", + "description": "Adds a board outline shape (rectangle, rounded_rectangle, circle, or polygon) on the Edge.Cuts layer. By default the board top-left corner is placed at (0, 0) so all coordinates are positive. Use x/y to set a different top-left corner position.", + "inputSchema": { + "type": "object", + "properties": { + "shape": { + "type": "string", + "enum": ["rectangle", "rounded_rectangle", "circle", "polygon"], + "description": "Shape type for the board outline", + }, + "width": { + "type": "number", + "description": "Width in mm (for rectangle/rounded_rectangle)", + "minimum": 1, + }, + "height": { + "type": "number", + "description": "Height in mm (for rectangle/rounded_rectangle)", + "minimum": 1, + }, + "x": { + "type": "number", + "description": "X coordinate of the top-left corner in mm (default: 0). Board extends from x to x+width.", + }, + "y": { + "type": "number", + "description": "Y coordinate of the top-left corner in mm (default: 0). Board extends from y to y+height.", + }, + "radius": { + "type": "number", + "description": "Corner radius in mm for rounded_rectangle, or radius for circle", + "minimum": 0, + }, + "points": { + "type": "array", + "description": "Array of {x, y} point objects in mm (for polygon shape only)", + "items": { + "type": "object", + "properties": { + "x": {"type": "number"}, + "y": {"type": "number"}, + }, + "required": ["x", "y"], + }, + "minItems": 3, + }, + }, + "required": ["shape"], + }, + }, + { + "name": "add_layer", + "title": "Add Custom Layer", + "description": "Adds a new custom layer to the board stack (e.g., User.1, User.Comments).", + "inputSchema": { + "type": "object", + "properties": { + "layerName": { + "type": "string", + "description": "Name of the layer to add", + }, + "layerType": { + "type": "string", + "enum": ["signal", "power", "mixed", "jumper"], + "description": "Type of layer (for copper layers)", + }, + }, + "required": ["layerName"], + }, + }, + { + "name": "set_active_layer", + "title": "Set Active Layer", + "description": "Sets the currently active layer for drawing operations.", + "inputSchema": { + "type": "object", + "properties": { + "layerName": { + "type": "string", + "description": "Name of the layer to make active (e.g., F.Cu, B.Cu, Edge.Cuts)", + } + }, + "required": ["layerName"], + }, + }, + { + "name": "get_layer_list", + "title": "List Board Layers", + "description": "Returns a list of all layers in the board with their properties.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "get_board_info", + "title": "Get Board Information", + "description": "Retrieves comprehensive board information including dimensions, layer count, component count, and design rules.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "get_board_2d_view", + "title": "Render Board Preview", + "description": "Generates a 2D visual representation of the current board state as a PNG image.", + "inputSchema": { + "type": "object", + "properties": { + "width": { + "type": "number", + "description": "Image width in pixels (default: 800)", + "minimum": 100, + "default": 800, + }, + "height": { + "type": "number", + "description": "Image height in pixels (default: 600)", + "minimum": 100, + "default": 600, + }, + }, + }, + }, + { + "name": "get_board_extents", + "title": "Get Board Bounding Box", + "description": "Returns the bounding box extents of the PCB board including all edge cuts, components, and traces.", + "inputSchema": { + "type": "object", + "properties": { + "unit": { + "type": "string", + "enum": ["mm", "inch"], + "description": "Unit for returned coordinates (default: mm)", + "default": "mm", + } + }, + }, + }, + { + "name": "add_mounting_hole", + "title": "Add Mounting Hole", + "description": "Adds a mounting hole (non-plated through hole) at the specified position with given diameter.", + "inputSchema": { + "type": "object", + "properties": { + "x": {"type": "number", "description": "X coordinate in millimeters"}, + "y": {"type": "number", "description": "Y coordinate in millimeters"}, + "diameter": { + "type": "number", + "description": "Hole diameter in millimeters", + "minimum": 0.1, + }, + }, + "required": ["x", "y", "diameter"], + }, + }, + { + "name": "import_svg_logo", + "title": "Import SVG Logo to PCB", + "description": "Imports an SVG file as filled graphic polygons onto a KiCAD PCB layer (default F.SilkS). Curves are linearised automatically. Supports path, rect, circle, ellipse, polygon and group transforms.", + "inputSchema": { + "type": "object", + "properties": { + "pcbPath": { + "type": "string", + "description": "Path to the .kicad_pcb file", + }, + "svgPath": { + "type": "string", + "description": "Path to the SVG logo file", + }, + "x": { + "type": "number", + "description": "X position of the logo top-left corner in mm", + }, + "y": { + "type": "number", + "description": "Y position of the logo top-left corner in mm", + }, + "width": { + "type": "number", + "description": "Target width of the logo in mm (height scaled to preserve aspect ratio)", + "minimum": 0.1, + }, + "layer": { + "type": "string", + "description": "PCB layer name, e.g. F.SilkS or B.SilkS (default: F.SilkS)", + "default": "F.SilkS", + }, + "strokeWidth": { + "type": "number", + "description": "Outline stroke width in mm (0 = no outline, default 0)", + "default": 0, + }, + "filled": { + "type": "boolean", + "description": "Fill polygons with solid layer colour (default true)", + "default": True, + }, + }, + "required": ["pcbPath", "svgPath", "x", "y", "width"], + }, + }, + { + "name": "add_board_text", + "title": "Add Text to Board", + "description": "Adds text annotation to the board on a specified layer (e.g., F.SilkS for top silkscreen).", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "Text content to add", + "minLength": 1, + }, + "x": {"type": "number", "description": "X coordinate in millimeters"}, + "y": {"type": "number", "description": "Y coordinate in millimeters"}, + "layer": { + "type": "string", + "description": "Layer name (e.g., F.SilkS, B.SilkS, F.Cu)", + "default": "F.SilkS", + }, + "size": { + "type": "number", + "description": "Text size in millimeters", + "minimum": 0.1, + "default": 1.0, + }, + "thickness": { + "type": "number", + "description": "Text thickness in millimeters", + "minimum": 0.01, + "default": 0.15, + }, + }, + "required": ["text", "x", "y"], + }, + }, +] + +# ============================================================================= +# COMPONENT TOOLS +# ============================================================================= + +COMPONENT_TOOLS = [ + { + "name": "place_component", + "title": "Place Component", + "description": "Places a component with specified footprint at given coordinates on the board.", + "inputSchema": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "Component reference designator (e.g., R1, C2, U3)", + }, + "footprint": { + "type": "string", + "description": "Footprint library:name (e.g., Resistor_SMD:R_0805_2012Metric)", + }, + "x": {"type": "number", "description": "X coordinate in millimeters"}, + "y": {"type": "number", "description": "Y coordinate in millimeters"}, + "rotation": { + "type": "number", + "description": "Rotation angle in degrees (0-360)", + "minimum": 0, + "maximum": 360, + "default": 0, + }, + "layer": { + "type": "string", + "enum": ["F.Cu", "B.Cu"], + "description": "Board layer (top or bottom)", + "default": "F.Cu", + }, + }, + "required": ["reference", "footprint", "x", "y"], + }, + }, + { + "name": "move_component", + "title": "Move Component", + "description": "Moves an existing component to a new position on the board.", + "inputSchema": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "Component reference designator", + }, + "x": { + "type": "number", + "description": "New X coordinate in millimeters", + }, + "y": { + "type": "number", + "description": "New Y coordinate in millimeters", + }, + }, + "required": ["reference", "x", "y"], + }, + }, + { + "name": "rotate_component", + "title": "Rotate Component", + "description": "Rotates a component by specified angle. Rotation is cumulative with existing rotation.", + "inputSchema": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "Component reference designator", + }, + "angle": { + "type": "number", + "description": "Rotation angle in degrees (positive = counterclockwise)", + }, + }, + "required": ["reference", "angle"], + }, + }, + { + "name": "delete_component", + "title": "Delete Component", + "description": "Removes a component from the board.", + "inputSchema": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "Component reference designator", + } + }, + "required": ["reference"], + }, + }, + { + "name": "edit_component", + "title": "Edit Component Properties", + "description": "Modifies properties of an existing component (value, footprint, etc.).", + "inputSchema": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "Component reference designator", + }, + "value": {"type": "string", "description": "New component value"}, + "footprint": { + "type": "string", + "description": "New footprint library:name", + }, + }, + "required": ["reference"], + }, + }, + { + "name": "get_component_properties", + "title": "Get Component Properties", + "description": "Retrieves detailed properties of a specific component.", + "inputSchema": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "Component reference designator", + } + }, + "required": ["reference"], + }, + }, + { + "name": "get_component_list", + "title": "List All Components", + "description": "Returns a list of all components on the board with their properties.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "find_component", + "title": "Find Components", + "description": "Searches for components matching specified criteria. Supports partial matching on reference, value, or footprint patterns.", + "inputSchema": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "Reference designator pattern to match (e.g., 'R1', 'U', 'C2')", + }, + "value": { + "type": "string", + "description": "Value pattern to match (e.g., '10k', '100nF')", + }, + "footprint": { + "type": "string", + "description": "Footprint pattern to match (e.g., '0805', 'SOIC')", + }, + }, + }, + }, + { + "name": "get_component_pads", + "title": "Get Component Pads", + "description": "Returns all pads for a component with their positions, net connections, sizes, and shapes.", + "inputSchema": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "Component reference designator (e.g., U1, R5)", + } + }, + "required": ["reference"], + }, + }, + { + "name": "get_pad_position", + "title": "Get Pad Position", + "description": "Returns the position and properties of a specific pad on a component.", + "inputSchema": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "Component reference designator", + }, + "padName": { + "type": "string", + "description": "Pad name or number (e.g., '1', '2', 'A1')", + }, + "padNumber": { + "type": "string", + "description": "Alternative to padName - pad number", + }, + }, + "required": ["reference"], + }, + }, + { + "name": "place_component_array", + "title": "Place Component Array", + "description": "Places multiple copies of a component in a grid or circular pattern.", + "inputSchema": { + "type": "object", + "properties": { + "referencePrefix": { + "type": "string", + "description": "Reference prefix (e.g., 'R' for R1, R2, R3...)", + }, + "startNumber": { + "type": "integer", + "description": "Starting number for references", + "minimum": 1, + "default": 1, + }, + "footprint": { + "type": "string", + "description": "Footprint library:name", + }, + "pattern": { + "type": "string", + "enum": ["grid", "circular"], + "description": "Array pattern type", + }, + "count": { + "type": "integer", + "description": "Total number of components to place", + "minimum": 1, + }, + "startX": { + "type": "number", + "description": "Starting X coordinate in millimeters", + }, + "startY": { + "type": "number", + "description": "Starting Y coordinate in millimeters", + }, + "spacingX": { + "type": "number", + "description": "Horizontal spacing in mm (for grid pattern)", + }, + "spacingY": { + "type": "number", + "description": "Vertical spacing in mm (for grid pattern)", + }, + "radius": { + "type": "number", + "description": "Circle radius in mm (for circular pattern)", + }, + "rows": { + "type": "integer", + "description": "Number of rows (for grid pattern)", + "minimum": 1, + }, + "columns": { + "type": "integer", + "description": "Number of columns (for grid pattern)", + "minimum": 1, + }, + }, + "required": [ + "referencePrefix", + "footprint", + "pattern", + "count", + "startX", + "startY", + ], + }, + }, + { + "name": "align_components", + "title": "Align Components", + "description": "Aligns multiple components horizontally or vertically.", + "inputSchema": { + "type": "object", + "properties": { + "references": { + "type": "array", + "description": "Array of component reference designators to align", + "items": {"type": "string"}, + "minItems": 2, + }, + "direction": { + "type": "string", + "enum": ["horizontal", "vertical"], + "description": "Alignment direction", + }, + "spacing": { + "type": "number", + "description": "Spacing between components in mm (optional, for even distribution)", + }, + }, + "required": ["references", "direction"], + }, + }, + { + "name": "duplicate_component", + "title": "Duplicate Component", + "description": "Creates a copy of an existing component with new reference designator.", + "inputSchema": { + "type": "object", + "properties": { + "sourceReference": { + "type": "string", + "description": "Reference of component to duplicate", + }, + "newReference": { + "type": "string", + "description": "Reference designator for the new component", + }, + "offsetX": { + "type": "number", + "description": "X offset from original position in mm", + "default": 0, + }, + "offsetY": { + "type": "number", + "description": "Y offset from original position in mm", + "default": 0, + }, + }, + "required": ["sourceReference", "newReference"], + }, + }, +] + +# ============================================================================= +# ROUTING TOOLS +# ============================================================================= + +ROUTING_TOOLS = [ + { + "name": "add_net", + "title": "Create Electrical Net", + "description": "Creates a new electrical net for signal routing.", + "inputSchema": { + "type": "object", + "properties": { + "netName": { + "type": "string", + "description": "Name of the net (e.g., VCC, GND, SDA)", + "minLength": 1, + }, + "netClass": { + "type": "string", + "description": "Optional net class to assign (must exist first)", + }, + }, + "required": ["netName"], + }, + }, + { + "name": "route_trace", + "title": "Route PCB Trace", + "description": "Routes a copper trace between two points or pads on a specified layer.", + "inputSchema": { + "type": "object", + "properties": { + "netName": {"type": "string", "description": "Net name for this trace"}, + "layer": { + "type": "string", + "description": "Layer to route on (e.g., F.Cu, B.Cu)", + "default": "F.Cu", + }, + "width": { + "type": "number", + "description": "Trace width in millimeters", + "minimum": 0.1, + }, + "points": { + "type": "array", + "description": "Array of [x, y] waypoints in millimeters", + "items": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + }, + "minItems": 2, + }, + }, + "required": ["points", "width"], + }, + }, + { + "name": "add_via", + "title": "Add Via", + "description": "Adds a via (plated through-hole) to connect traces between layers.", + "inputSchema": { + "type": "object", + "properties": { + "x": {"type": "number", "description": "X coordinate in millimeters"}, + "y": {"type": "number", "description": "Y coordinate in millimeters"}, + "diameter": { + "type": "number", + "description": "Via diameter in millimeters", + "minimum": 0.1, + }, + "drill": { + "type": "number", + "description": "Drill diameter in millimeters", + "minimum": 0.1, + }, + "netName": { + "type": "string", + "description": "Net name to assign to this via", + }, + }, + "required": ["x", "y", "diameter", "drill"], + }, + }, + { + "name": "delete_trace", + "title": "Delete Trace", + "description": "Removes traces from the board. Can delete by UUID, position, or bulk-delete all traces on a net.", + "inputSchema": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "description": "UUID of a specific trace to delete", + }, + "position": { + "type": "object", + "description": "Delete trace nearest to this position", + "properties": { + "x": {"type": "number", "description": "X coordinate"}, + "y": {"type": "number", "description": "Y coordinate"}, + "unit": { + "type": "string", + "enum": ["mm", "inch"], + "default": "mm", + }, + }, + "required": ["x", "y"], + }, + "net": { + "type": "string", + "description": "Delete all traces on this net (bulk delete)", + }, + "layer": { + "type": "string", + "description": "Filter by layer when using net-based deletion", + }, + "includeVias": { + "type": "boolean", + "description": "Include vias in net-based deletion", + "default": False, + }, + }, + }, + }, + { + "name": "query_traces", + "title": "Query Traces", + "description": "Queries traces on the board with optional filters by net, layer, or bounding box. Returns trace details including UUID, positions, width, and length.", + "inputSchema": { + "type": "object", + "properties": { + "net": { + "type": "string", + "description": "Filter by net name (e.g., 'GND', 'VCC')", + }, + "layer": { + "type": "string", + "description": "Filter by layer name (e.g., 'F.Cu', 'B.Cu')", + }, + "boundingBox": { + "type": "object", + "description": "Filter by bounding box region", + "properties": { + "x1": {"type": "number", "description": "Left X coordinate"}, + "y1": {"type": "number", "description": "Top Y coordinate"}, + "x2": {"type": "number", "description": "Right X coordinate"}, + "y2": {"type": "number", "description": "Bottom Y coordinate"}, + "unit": { + "type": "string", + "enum": ["mm", "inch"], + "default": "mm", + }, + }, + }, + "includeVias": { + "type": "boolean", + "description": "Include vias in the result", + "default": False, + }, + }, + }, + }, + { + "name": "modify_trace", + "title": "Modify Trace", + "description": "Modifies properties of an existing trace. Find trace by UUID or position, then change width, layer, or net assignment.", + "inputSchema": { + "type": "object", + "properties": { + "uuid": { + "type": "string", + "description": "UUID of the trace to modify", + }, + "position": { + "type": "object", + "description": "Find trace nearest to this position", + "properties": { + "x": {"type": "number", "description": "X coordinate"}, + "y": {"type": "number", "description": "Y coordinate"}, + "unit": { + "type": "string", + "enum": ["mm", "inch"], + "default": "mm", + }, + }, + "required": ["x", "y"], + }, + "width": {"type": "number", "description": "New trace width in mm"}, + "layer": { + "type": "string", + "description": "New layer name (e.g., 'F.Cu', 'B.Cu')", + }, + "net": {"type": "string", "description": "New net name to assign"}, + }, + }, + }, + { + "name": "copy_routing_pattern", + "title": "Copy Routing Pattern", + "description": "Copies routing pattern from source components to target components. Enables routing replication between identical component groups by calculating and applying position offset.", + "inputSchema": { + "type": "object", + "properties": { + "sourceRefs": { + "type": "array", + "items": {"type": "string"}, + "description": "Source component references (e.g., ['U1', 'U2', 'U3'])", + }, + "targetRefs": { + "type": "array", + "items": {"type": "string"}, + "description": "Target component references (e.g., ['U4', 'U5', 'U6'])", + }, + "includeVias": { + "type": "boolean", + "description": "Include vias in the pattern copy", + "default": True, + }, + "traceWidth": { + "type": "number", + "description": "Override trace width in mm (uses original if not specified)", + }, + }, + "required": ["sourceRefs", "targetRefs"], + }, + }, + { + "name": "get_nets_list", + "title": "List All Nets", + "description": "Returns a list of all electrical nets defined on the board.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "create_netclass", + "title": "Create Net Class", + "description": "Defines a net class with specific routing rules (trace width, clearance, etc.).", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Net class name", + "minLength": 1, + }, + "traceWidth": { + "type": "number", + "description": "Default trace width in millimeters", + "minimum": 0.1, + }, + "clearance": { + "type": "number", + "description": "Clearance in millimeters", + "minimum": 0.1, + }, + "viaDiameter": { + "type": "number", + "description": "Via diameter in millimeters", + }, + "viaDrill": { + "type": "number", + "description": "Via drill diameter in millimeters", + }, + }, + "required": ["name", "traceWidth", "clearance"], + }, + }, + { + "name": "add_copper_pour", + "title": "Add Copper Pour", + "description": "Creates a copper pour/zone (typically for ground or power planes).", + "inputSchema": { + "type": "object", + "properties": { + "netName": { + "type": "string", + "description": "Net to connect this copper pour to (e.g., GND, VCC)", + }, + "layer": { + "type": "string", + "description": "Layer for the copper pour (e.g., F.Cu, B.Cu)", + }, + "priority": { + "type": "integer", + "description": "Pour priority (higher priorities fill first)", + "minimum": 0, + "default": 0, + }, + "clearance": { + "type": "number", + "description": "Clearance from other objects in millimeters", + "minimum": 0.1, + }, + "outline": { + "type": "array", + "description": "Array of [x, y] points defining the pour boundary", + "items": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + }, + "minItems": 3, + }, + }, + "required": ["netName", "layer", "outline"], + }, + }, + { + "name": "route_differential_pair", + "title": "Route Differential Pair", + "description": "Routes a differential signal pair with matched lengths and spacing.", + "inputSchema": { + "type": "object", + "properties": { + "positiveName": { + "type": "string", + "description": "Positive signal net name", + }, + "negativeName": { + "type": "string", + "description": "Negative signal net name", + }, + "layer": {"type": "string", "description": "Layer to route on"}, + "width": { + "type": "number", + "description": "Trace width in millimeters", + }, + "gap": { + "type": "number", + "description": "Gap between traces in millimeters", + }, + "points": { + "type": "array", + "description": "Waypoints for the pair routing", + "items": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + }, + "minItems": 2, + }, + }, + "required": ["positiveName", "negativeName", "width", "gap", "points"], + }, + }, +] + +# ============================================================================= +# LIBRARY TOOLS +# ============================================================================= + +LIBRARY_TOOLS = [ + { + "name": "list_libraries", + "title": "List Footprint Libraries", + "description": "Lists all available footprint libraries accessible to KiCAD.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "search_footprints", + "title": "Search Footprints", + "description": "Searches for footprints matching a query string across all libraries.", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query (e.g., '0805', 'SOIC', 'QFP')", + "minLength": 1, + }, + "library": { + "type": "string", + "description": "Optional library to restrict search to", + }, + }, + "required": ["query"], + }, + }, + { + "name": "list_library_footprints", + "title": "List Footprints in Library", + "description": "Lists all footprints available in a specific library.", + "inputSchema": { + "type": "object", + "properties": { + "library": { + "type": "string", + "description": "Library name (e.g., Resistor_SMD, Connector_PinHeader)", + "minLength": 1, + } + }, + "required": ["library"], + }, + }, + { + "name": "get_footprint_info", + "title": "Get Footprint Details", + "description": "Retrieves detailed information about a specific footprint including pad layout, dimensions, and description.", + "inputSchema": { + "type": "object", + "properties": { + "library": {"type": "string", "description": "Library name"}, + "footprint": {"type": "string", "description": "Footprint name"}, + }, + "required": ["library", "footprint"], + }, + }, +] + +# ============================================================================= +# DESIGN RULE TOOLS +# ============================================================================= + +DESIGN_RULE_TOOLS = [ + { + "name": "set_design_rules", + "title": "Set Design Rules", + "description": "Configures board design rules including clearances, trace widths, and via sizes.", + "inputSchema": { + "type": "object", + "properties": { + "clearance": { + "type": "number", + "description": "Minimum clearance between copper in millimeters", + "minimum": 0.1, + }, + "trackWidth": { + "type": "number", + "description": "Minimum track width in millimeters", + "minimum": 0.1, + }, + "viaDiameter": { + "type": "number", + "description": "Minimum via diameter in millimeters", + }, + "viaDrill": { + "type": "number", + "description": "Minimum via drill diameter in millimeters", + }, + "microViaD iameter": { + "type": "number", + "description": "Minimum micro-via diameter in millimeters", + }, + }, + }, + }, + { + "name": "get_design_rules", + "title": "Get Current Design Rules", + "description": "Retrieves the currently configured design rules from the board.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "run_drc", + "title": "Run Design Rule Check", + "description": "Executes a design rule check (DRC) on the current board and reports violations.", + "inputSchema": { + "type": "object", + "properties": { + "includeWarnings": { + "type": "boolean", + "description": "Include warnings in addition to errors", + "default": True, + } + }, + }, + }, + { + "name": "get_drc_violations", + "title": "Get DRC Violations", + "description": "Returns a list of design rule violations from the most recent DRC run.", + "inputSchema": {"type": "object", "properties": {}}, + }, +] + +# ============================================================================= +# EXPORT TOOLS +# ============================================================================= + +EXPORT_TOOLS = [ + { + "name": "export_gerber", + "title": "Export Gerber Files", + "description": "Generates Gerber files for PCB fabrication (industry standard format).", + "inputSchema": { + "type": "object", + "properties": { + "outputPath": { + "type": "string", + "description": "Directory path for output files", + }, + "layers": { + "type": "array", + "description": "List of layers to export (if not provided, exports all copper and mask layers)", + "items": {"type": "string"}, + }, + "includeDrillFiles": { + "type": "boolean", + "description": "Include drill files (Excellon format)", + "default": True, + }, + }, + "required": ["outputPath"], + }, + }, + { + "name": "export_pdf", + "title": "Export PDF", + "description": "Exports the board layout as a PDF document for documentation or review.", + "inputSchema": { + "type": "object", + "properties": { + "outputPath": { + "type": "string", + "description": "Path for output PDF file", + }, + "layers": { + "type": "array", + "description": "Layers to include in PDF", + "items": {"type": "string"}, + }, + "colorMode": { + "type": "string", + "enum": ["color", "black_white"], + "description": "Color mode for output", + "default": "color", + }, + }, + "required": ["outputPath"], + }, + }, + { + "name": "export_svg", + "title": "Export SVG", + "description": "Exports the board as Scalable Vector Graphics for documentation or web display.", + "inputSchema": { + "type": "object", + "properties": { + "outputPath": { + "type": "string", + "description": "Path for output SVG file", + }, + "layers": { + "type": "array", + "description": "Layers to include in SVG", + "items": {"type": "string"}, + }, + }, + "required": ["outputPath"], + }, + }, + { + "name": "export_3d", + "title": "Export 3D Model", + "description": "Exports a 3D model of the board in STEP or VRML format for mechanical CAD integration.", + "inputSchema": { + "type": "object", + "properties": { + "outputPath": { + "type": "string", + "description": "Path for output 3D file", + }, + "format": { + "type": "string", + "enum": ["step", "vrml"], + "description": "3D model format", + "default": "step", + }, + "includeComponents": { + "type": "boolean", + "description": "Include 3D component models", + "default": True, + }, + }, + "required": ["outputPath"], + }, + }, + { + "name": "export_bom", + "title": "Export Bill of Materials", + "description": "Generates a bill of materials (BOM) listing all components with references, values, and footprints.", + "inputSchema": { + "type": "object", + "properties": { + "outputPath": { + "type": "string", + "description": "Path for output BOM file", + }, + "format": { + "type": "string", + "enum": ["csv", "xml", "html"], + "description": "BOM output format", + "default": "csv", + }, + "groupByValue": { + "type": "boolean", + "description": "Group components with same value together", + "default": True, + }, + }, + "required": ["outputPath"], + }, + }, +] + +# ============================================================================= +# SCHEMATIC TOOLS +# ============================================================================= + +SCHEMATIC_TOOLS = [ + { + "name": "create_schematic", + "title": "Create New Schematic", + "description": "Creates a new KiCAD schematic file for circuit design.", + "inputSchema": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "Path for the new schematic file (.kicad_sch)", + }, + "title": {"type": "string", "description": "Schematic title"}, + }, + "required": ["filename"], + }, + }, + { + "name": "load_schematic", + "title": "Load Existing Schematic", + "description": "Opens an existing KiCAD schematic file for editing.", + "inputSchema": { + "type": "object", + "properties": { + "filename": { + "type": "string", + "description": "Path to schematic file (.kicad_sch)", + } + }, + "required": ["filename"], + }, + }, + { + "name": "add_schematic_component", + "title": "Add Component to Schematic", + "description": "Places a symbol (resistor, capacitor, IC, etc.) on the schematic.", + "inputSchema": { + "type": "object", + "properties": { + "reference": { + "type": "string", + "description": "Reference designator (e.g., R1, C2, U3)", + }, + "symbol": { + "type": "string", + "description": "Symbol library:name (e.g., Device:R, Device:C)", + }, + "value": { + "type": "string", + "description": "Component value (e.g., 10k, 0.1uF)", + }, + "x": {"type": "number", "description": "X coordinate on schematic"}, + "y": {"type": "number", "description": "Y coordinate on schematic"}, + }, + "required": ["reference", "symbol", "x", "y"], + }, + }, + { + "name": "add_schematic_wire", + "title": "Draw Wire Between Pins", + "description": "Draws a wire on the schematic between two or more coordinate points. Always call get_schematic_pin_locations first to get the approximate pin coordinates, then pass them as the first and last waypoints. snapToPins (on by default) will correct any float imprecision by snapping endpoints to the exact nearest pin coordinate. To route around components, add intermediate waypoints between the start and end: e.g. [[x1,y1], [xMid,y1], [xMid,y2], [x2,y2]] routes horizontally then vertically. Intermediate waypoints are never snapped.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to schematic file", + }, + "waypoints": { + "type": "array", + "description": "Array of [x, y] coordinates defining the wire path. First and last points are the pin locations (from get_schematic_pin_locations). Add intermediate points to route around obstacles.", + "items": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + }, + "minItems": 2, + }, + "snapToPins": { + "type": "boolean", + "description": "When true, the first and last waypoints are snapped to the nearest schematic pin within snapTolerance mm. Intermediate waypoints are left unchanged. Enabled by default to correct float coordinate imprecision.", + "default": True, + }, + "snapTolerance": { + "type": "number", + "description": "Maximum distance in mm to search for a nearby pin when snapToPins is enabled.", + "default": 1.0, + }, + }, + "required": ["schematicPath", "waypoints"], + }, + }, + { + "name": "add_schematic_net_label", + "title": "Add Net Label", + "description": ( + "Add a net label to a schematic. " + "PREFERRED: supply componentRef + pinNumber to snap the label to the exact pin endpoint — " + "this guarantees an electrical connection. " + "Alternatively supply position [x, y], but the coordinates must match the pin endpoint exactly " + "(even a 0.01 mm offset breaks the connection). " + "The response includes actual_position (coordinates actually used) and snapped_to_pin " + "(present when a pin reference was resolved)." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to schematic file", + }, + "netName": { + "type": "string", + "description": "Name of the net (e.g., VCC, GND, SDA)", + }, + "position": { + "type": "array", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + "description": "Position [x, y] for the label. Required when componentRef/pinNumber are not given.", + }, + "componentRef": { + "type": "string", + "description": "Component reference to snap label to (e.g. U1, R1). Use with pinNumber.", + }, + "pinNumber": { + "type": "string", + "description": "Pin number or name on componentRef (e.g. '1', 'GND'). Use with componentRef.", + }, + "labelType": { + "type": "string", + "enum": ["label", "global_label", "hierarchical_label"], + "description": "Label type (default: label)", + "default": "label", + }, + "orientation": { + "type": "number", + "description": "Rotation angle in degrees (0, 90, 180, 270)", + "default": 0, + }, + }, + "required": ["schematicPath", "netName"], + }, + }, + { + "name": "connect_to_net", + "title": "Connect Pin to Net", + "description": ( + "Connect a component pin to a named net by adding a wire stub and net label at the exact " + "pin endpoint. The response includes pin_location (exact pin coords), label_location " + "(where the label was placed), and wire_stub (the wire segment added) so you can confirm " + "the placement without a separate verification call." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to schematic file", + }, + "componentRef": { + "type": "string", + "description": "Component reference designator (e.g., R1, U3)", + }, + "pinName": { + "type": "string", + "description": "Pin number or name on the component", + }, + "netName": { + "type": "string", + "description": "Name of the net to connect to", + }, + }, + "required": ["schematicPath", "componentRef", "pinName", "netName"], + }, + }, + { + "name": "get_net_connections", + "title": "Get Net Connections", + "description": "Returns all components and pins connected to a specified net.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to schematic file", + }, + "netName": { + "type": "string", + "description": "Name of the net to query", + }, + }, + "required": ["schematicPath", "netName"], + }, + }, + { + "name": "get_wire_connections", + "title": "Get Wire Connections", + "description": ( + "Returns the net name and all wires and component pins connected at a given point. " + "Accepts either a component reference + pin number (e.g. reference='U1', pin='3') " + "or a schematic coordinate (x, y in mm). " + "The response includes: 'net' (label name or null for unnamed nets), " + "'pins' (all component pins on the net), 'wires' (all wire segments on the net), " + "and 'query_point' (the resolved coordinate used). " + "The query point must be at a wire endpoint or junction — wire midpoints are not matched. " + "Use get_schematic_pin_locations or list_schematic_wires to obtain exact endpoint coordinates." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the schematic file (.kicad_sch)", + }, + "reference": { + "type": "string", + "description": "Component reference (e.g. U1, R1). Pair with pin.", + }, + "pin": { + "type": "string", + "description": "Pin number or name (e.g. '3', 'SDA'). Pair with reference.", + }, + "x": { + "type": "number", + "description": "X coordinate of a wire endpoint in mm. Pair with y.", + }, + "y": { + "type": "number", + "description": "Y coordinate of a wire endpoint in mm. Pair with x.", + }, + }, + "required": ["schematicPath"], + }, + }, + { + "name": "get_net_at_point", + "title": "Get Net At Point", + "description": ( + "Returns the net name at a given (x, y) coordinate in a schematic, " + "or null if no net label or wire endpoint is present at that position. " + "Checks net label positions first, then wire endpoints. " + "Useful for quickly identifying what net occupies a specific coordinate " + "without traversing the full wire graph." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the schematic file (.kicad_sch)", + }, + "x": { + "type": "number", + "description": "X coordinate in mm", + }, + "y": { + "type": "number", + "description": "Y coordinate in mm", + }, + }, + "required": ["schematicPath", "x", "y"], + }, + }, + { + "name": "get_schematic_pin_locations", + "title": "Get Schematic Pin Locations", + "description": "Returns the exact absolute coordinates of all pins on a schematic component. Use this BEFORE placing net labels with add_schematic_net_label to get the correct x/y position for each pin endpoint.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the schematic file", + }, + "reference": { + "type": "string", + "description": "Component reference designator (e.g., U1, R1, J2)", + }, + }, + "required": ["schematicPath", "reference"], + }, + }, + { + "name": "connect_passthrough", + "title": "Connect Passthrough (Pin-to-Pin)", + "description": "Connects all pins of a source connector to the matching pins of a target connector using shared net labels. Ideal for passthrough adapters where J1 pin N connects directly to J2 pin N. Each pair gets a net label '{netPrefix}_{pinNumber}'. Use this instead of calling connect_to_net 15 times for FFC/ribbon cable passthroughs. NOTE: KiCAD Connector_Generic symbols always have pin 1 at the TOP of the symbol and pin N at the BOTTOM. When assigning named nets (e.g. GND, CAM_SCL) to specific pin numbers, always use the physical pin number as shown in the connector datasheet — pin 1 = top of symbol.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the schematic file", + }, + "sourceRef": { + "type": "string", + "description": "Reference of the source connector (e.g., J1)", + }, + "targetRef": { + "type": "string", + "description": "Reference of the target connector (e.g., J2)", + }, + "netPrefix": { + "type": "string", + "description": "Prefix for generated net names, e.g. 'CSI' produces CSI_1, CSI_2, ... (default: PIN)", + }, + "pinOffset": { + "type": "integer", + "description": "Add this value to the pin number when building net names (default: 0)", + }, + }, + "required": ["schematicPath", "sourceRef", "targetRef"], + }, + }, + { + "name": "run_erc", + "title": "Run Electrical Rules Check (ERC)", + "description": "Runs the KiCAD Electrical Rules Check (ERC) on a schematic via kicad-cli and returns all violations with type, severity, and location. Use this to verify the schematic is electrically correct before generating a netlist or exporting.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file", + } + }, + "required": ["schematicPath"], + }, + }, + { + "name": "sync_schematic_to_board", + "title": "Sync Schematic to PCB (F8)", + "description": "Reads net connections from the schematic and assigns them to matching component pads in the PCB board file. Equivalent to KiCAD Pcbnew F8 'Update PCB from Schematic'. Must be called after placing components and before routing traces, so that pad-to-net assignments are correct.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to .kicad_sch file. If omitted, auto-detected from current board path.", + }, + "boardPath": { + "type": "string", + "description": "Path to .kicad_pcb file. If omitted, uses currently loaded board.", + }, + }, + }, + }, + { + "name": "generate_netlist", + "title": "Generate Netlist (JSON)", + "description": ( + "Returns a structured JSON netlist from the schematic: component list " + "(reference, value, footprint) and net list (net name + all connected " + "component/pin pairs). Uses kicad-cli internally — requires a saved " + ".kicad_sch file. For writing to a file or exporting SPICE/Cadstar/OrcadPCB2 " + "format, use export_netlist instead." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Absolute path to the .kicad_sch schematic file", + }, + }, + "required": ["schematicPath"], + }, + }, + { + "name": "list_schematic_libraries", + "title": "List Symbol Libraries", + "description": "Lists all available symbol libraries for schematic design.", + "inputSchema": { + "type": "object", + "properties": { + "searchPaths": { + "type": "array", + "description": "Optional additional paths to search for libraries", + "items": {"type": "string"}, + } + }, + }, + }, + { + "name": "export_schematic_pdf", + "title": "Export Schematic to PDF", + "description": "Exports the schematic as a PDF document for printing or documentation.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to schematic file", + }, + "outputPath": {"type": "string", "description": "Path for output PDF"}, + }, + "required": ["schematicPath", "outputPath"], + }, + }, + { + "name": "add_schematic_junction", + "title": "Add Junction to Schematic", + "description": "Adds a junction (connection dot) at the specified coordinates on the schematic. Junctions are required in KiCAD to mark intentional connections where wires cross or where a wire branches off another wire. Without a junction, crossing wires are not electrically connected.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to schematic file", + }, + "position": { + "type": "array", + "description": "The [x, y] coordinates where the junction should be placed. Must be on an existing wire intersection or branch point.", + "items": {"type": "number"}, + "minItems": 2, + "maxItems": 2, + }, + }, + "required": ["schematicPath", "position"], + }, + }, + # --- Schematic Analysis Tools (read-only) --- + { + "name": "get_schematic_view_region", + "title": "Get Schematic View Region", + "description": "Exports a cropped region of the schematic as an image (PNG or SVG). Specify a bounding box in schematic mm coordinates to zoom into a specific area.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file", + }, + "x1": { + "type": "number", + "description": "Left X coordinate of the region in mm", + }, + "y1": { + "type": "number", + "description": "Top Y coordinate of the region in mm", + }, + "x2": { + "type": "number", + "description": "Right X coordinate of the region in mm", + }, + "y2": { + "type": "number", + "description": "Bottom Y coordinate of the region in mm", + }, + "format": { + "type": "string", + "enum": ["png", "svg"], + "description": "Output image format (default: png)", + }, + "width": { + "type": "integer", + "description": "Output image width in pixels (default: 800)", + }, + "height": { + "type": "integer", + "description": "Output image height in pixels (default: 600)", + }, + }, + "required": ["schematicPath", "x1", "y1", "x2", "y2"], + }, + }, + { + "name": "find_overlapping_elements", + "title": "Find Overlapping Elements", + "description": "Detects spatially overlapping symbols, wires, and labels in the schematic. Finds: duplicate power symbols at the same position, collinear overlapping wire segments, and labels stacked on top of each other.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file", + }, + "tolerance": { + "type": "number", + "description": "Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection. (default: 0.5)", + }, + }, + "required": ["schematicPath"], + }, + }, + { + "name": "get_elements_in_region", + "title": "Get Elements in Region", + "description": "Lists all symbols, wires, and labels within a rectangular region of the schematic. Useful for understanding what is in a specific area before modifying it.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file", + }, + "x1": { + "type": "number", + "description": "Left X coordinate of the region in mm", + }, + "y1": { + "type": "number", + "description": "Top Y coordinate of the region in mm", + }, + "x2": { + "type": "number", + "description": "Right X coordinate of the region in mm", + }, + "y2": { + "type": "number", + "description": "Bottom Y coordinate of the region in mm", + }, + }, + "required": ["schematicPath", "x1", "y1", "x2", "y2"], + }, + }, + { + "name": "find_wires_crossing_symbols", + "title": "Find Wires Crossing Symbols", + "description": "Find all wires that cross over component symbol bodies. Wires passing over symbols are unacceptable in schematics — they indicate routing mistakes where a wire was drawn across a component instead of around it.", + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file", + } + }, + "required": ["schematicPath"], + }, + }, + { + "name": "find_orphaned_wires", + "title": "Find Orphaned Wires", + "description": ( + "Find wire segments with at least one dangling endpoint — an endpoint not connected " + "to a component pin, net label, or another wire. " + "Orphaned wires cause ERC 'wire end unconnected' errors and indicate routing mistakes. " + "Does not require the KiCad UI to be running." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file", + } + }, + "required": ["schematicPath"], + }, + }, + { + "name": "list_floating_labels", + "title": "List Floating Net Labels", + "description": ( + "Returns all net labels in the schematic that are not connected to any component pin. " + "A label is 'floating' when no component pin's coordinate falls on the wire-network " + "reachable from the label's anchor position. " + "Floating labels indicate misplaced or off-grid labels that will cause ERC errors. " + "Does not require the KiCad UI to be running." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file", + } + }, + "required": ["schematicPath"], + }, + }, + { + "name": "snap_to_grid", + "title": "Snap Schematic Elements to Grid", + "description": ( + "Snap schematic element coordinates to the nearest grid point. " + "KiCAD eeschema uses exact integer matching (10 000 IU/mm) for connectivity, " + "so even a sub-pixel coordinate offset will make wires appear connected visually " + "but fail ERC checks. Running this tool before ERC eliminates that class of error. " + "Modifies the .kicad_sch file in place. " + "Does not require the KiCAD UI to be running." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the .kicad_sch schematic file", + }, + "gridSize": { + "type": "number", + "description": ( + "Grid spacing in mm (default: 1.27 — standard KiCAD schematic grid). " + "Do NOT use 2.54: half of all valid KiCAD pin positions are at odd " + "multiples of 1.27 mm and would be displaced 1.27 mm, breaking " + "connectivity." + ), + "default": 1.27, + }, + "elements": { + "type": "array", + "description": ( + "Element types to snap. " + 'Valid values: "wires", "junctions", "labels", "components". ' + 'Defaults to ["wires", "junctions", "labels"] when omitted. ' + '"components" is opt-in because moving a component without re-routing ' + "its wires will create new mismatches." + ), + "items": { + "type": "string", + "enum": ["wires", "junctions", "labels", "components"], + }, + }, + }, + "required": ["schematicPath"], + }, + }, +] + +# ============================================================================= +# UI/PROCESS TOOLS +# ============================================================================= + +UI_TOOLS = [ + { + "name": "check_kicad_ui", + "title": "Check KiCAD UI Status", + "description": "Checks if KiCAD user interface is currently running and returns process information.", + "inputSchema": {"type": "object", "properties": {}}, + }, + { + "name": "launch_kicad_ui", + "title": "Launch KiCAD Application", + "description": "Opens the KiCAD graphical user interface, optionally with a specific project loaded.", + "inputSchema": { + "type": "object", + "properties": { + "projectPath": { + "type": "string", + "description": "Optional path to project file to open in UI", + }, + "autoLaunch": { + "type": "boolean", + "description": "Whether to automatically launch if not running", + "default": True, + }, + }, + }, + }, +] + +# ============================================================================= +# COMBINED TOOL SCHEMAS +# ============================================================================= + +TOOL_SCHEMAS: Dict[str, Any] = {} + +# Combine all tool categories +for tool in ( + PROJECT_TOOLS + + BOARD_TOOLS + + COMPONENT_TOOLS + + ROUTING_TOOLS + + LIBRARY_TOOLS + + DESIGN_RULE_TOOLS + + EXPORT_TOOLS + + SCHEMATIC_TOOLS + + UI_TOOLS +): + TOOL_SCHEMAS[tool["name"]] = tool + +# Total: 46 tools with comprehensive schemas diff --git a/python/templates/empty.kicad_sch b/python/templates/empty.kicad_sch index c8d39a8..11d2edb 100644 --- a/python/templates/empty.kicad_sch +++ b/python/templates/empty.kicad_sch @@ -1,137 +1,137 @@ -(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server") - - (uuid b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e) - - (paper "A4") - - (lib_symbols - (symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes) - (property "Reference" "R" (at 2.032 0 90) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "R" (at 0 0 90) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -1.778 0 90) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "R_0_1" - (rectangle (start -1.016 -2.54) (end 1.016 2.54) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - ) - (symbol "R_1_1" - (pin passive line (at 0 3.81 270) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 0 -3.81 90) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes) - (property "Reference" "C" (at 0.635 2.54 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Value" "C" (at 0.635 -2.54 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Footprint" "" (at 0.9652 -3.81 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "C_0_1" - (polyline - (pts - (xy -2.032 -0.762) - (xy 2.032 -0.762) - ) - (stroke (width 0.508) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy -2.032 0.762) - (xy 2.032 0.762) - ) - (stroke (width 0.508) (type default)) - (fill (type none)) - ) - ) - (symbol "C_1_1" - (pin passive line (at 0 3.81 270) (length 2.794) - (name "~" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 0 -3.81 90) (length 2.794) - (name "~" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) - (property "Reference" "D" (at 0 2.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "LED" (at 0 -2.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "LED_0_1" - (polyline - (pts - (xy -1.27 -1.27) - (xy -1.27 1.27) - ) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy -1.27 0) - (xy 1.27 0) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 1.27 -1.27) - (xy 1.27 1.27) - (xy -1.27 0) - (xy 1.27 -1.27) - ) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - ) - (symbol "LED_1_1" - (pin passive line (at -3.81 0 0) (length 2.54) - (name "K" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 3.81 0 180) (length 2.54) - (name "A" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - ) - - (sheet_instances - (path "/" (page "1")) - ) -) +(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server") + + (uuid b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e) + + (paper "A4") + + (lib_symbols + (symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes) + (property "Reference" "R" (at 2.032 0 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "R" (at 0 0 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at -1.778 0 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "R_0_1" + (rectangle (start -1.016 -2.54) (end 1.016 2.54) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + ) + (symbol "R_1_1" + (pin passive line (at 0 3.81 270) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 0 -3.81 90) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes) + (property "Reference" "C" (at 0.635 2.54 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Value" "C" (at 0.635 -2.54 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Footprint" "" (at 0.9652 -3.81 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "C_0_1" + (polyline + (pts + (xy -2.032 -0.762) + (xy 2.032 -0.762) + ) + (stroke (width 0.508) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy -2.032 0.762) + (xy 2.032 0.762) + ) + (stroke (width 0.508) (type default)) + (fill (type none)) + ) + ) + (symbol "C_1_1" + (pin passive line (at 0 3.81 270) (length 2.794) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 0 -3.81 90) (length 2.794) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) + (property "Reference" "D" (at 0 2.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "LED" (at 0 -2.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "LED_0_1" + (polyline + (pts + (xy -1.27 -1.27) + (xy -1.27 1.27) + ) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy -1.27 0) + (xy 1.27 0) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 1.27 -1.27) + (xy 1.27 1.27) + (xy -1.27 0) + (xy 1.27 -1.27) + ) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + ) + (symbol "LED_1_1" + (pin passive line (at -3.81 0 0) (length 2.54) + (name "K" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 3.81 0 180) (length 2.54) + (name "A" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + ) + + (sheet_instances + (path "/" (page "1")) + ) +) diff --git a/python/templates/template_with_symbols.kicad_sch b/python/templates/template_with_symbols.kicad_sch index f6d31c8..dc0b89b 100644 --- a/python/templates/template_with_symbols.kicad_sch +++ b/python/templates/template_with_symbols.kicad_sch @@ -1,194 +1,194 @@ -(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server") - - (uuid a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d) - - (paper "A4") - - (lib_symbols - (symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes) - (property "Reference" "R" (at 2.032 0 90) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "R" (at 0 0 90) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -1.778 0 90) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "R_0_1" - (rectangle (start -1.016 -2.54) (end 1.016 2.54) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - ) - (symbol "R_1_1" - (pin passive line (at 0 3.81 270) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 0 -3.81 90) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes) - (property "Reference" "C" (at 0.635 2.54 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Value" "C" (at 0.635 -2.54 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Footprint" "" (at 0.9652 -3.81 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "C_0_1" - (polyline - (pts - (xy -2.032 -0.762) - (xy 2.032 -0.762) - ) - (stroke (width 0.508) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy -2.032 0.762) - (xy 2.032 0.762) - ) - (stroke (width 0.508) (type default)) - (fill (type none)) - ) - ) - (symbol "C_1_1" - (pin passive line (at 0 3.81 270) (length 2.794) - (name "~" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 0 -3.81 90) (length 2.794) - (name "~" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) - (property "Reference" "D" (at 0 2.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "LED" (at 0 -2.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "LED_0_1" - (polyline - (pts - (xy -1.27 -1.27) - (xy -1.27 1.27) - ) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy -1.27 0) - (xy 1.27 0) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 1.27 -1.27) - (xy 1.27 1.27) - (xy -1.27 0) - (xy 1.27 -1.27) - ) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - ) - (symbol "LED_1_1" - (pin passive line (at -3.81 0 0) (length 2.54) - (name "K" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 3.81 0 180) (length 2.54) - (name "A" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - ) - - (symbol (lib_id "Device:R") (at -100 -100 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-000000000001) - (property "Reference" "_TEMPLATE_R" (at -100 -102.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "R_TEMPLATE" (at -100 -100 90) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -100 90) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -100 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid 00000000-0000-0000-0000-000000000010)) - (pin "2" (uuid 00000000-0000-0000-0000-000000000011)) - ) - - (symbol (lib_id "Device:C") (at -100 -110 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-000000000002) - (property "Reference" "_TEMPLATE_C" (at -100 -107.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "C_TEMPLATE" (at -100 -112.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -110 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -110 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid 00000000-0000-0000-0000-000000000020)) - (pin "2" (uuid 00000000-0000-0000-0000-000000000021)) - ) - - (symbol (lib_id "Device:LED") (at -100 -120 0) (unit 1) - (in_bom no) (on_board no) (dnp yes) - (uuid 00000000-0000-0000-0000-000000000003) - (property "Reference" "_TEMPLATE_D" (at -100 -117.46 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "LED_TEMPLATE" (at -100 -122.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -100 -120 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at -100 -120 0) - (effects (font (size 1.27 1.27)) hide) - ) - (pin "1" (uuid 00000000-0000-0000-0000-000000000030)) - (pin "2" (uuid 00000000-0000-0000-0000-000000000031)) - ) - - (sheet_instances - (path "/" (page "1")) - ) -) +(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server") + + (uuid a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d) + + (paper "A4") + + (lib_symbols + (symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes) + (property "Reference" "R" (at 2.032 0 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "R" (at 0 0 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at -1.778 0 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "R_0_1" + (rectangle (start -1.016 -2.54) (end 1.016 2.54) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + ) + (symbol "R_1_1" + (pin passive line (at 0 3.81 270) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 0 -3.81 90) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes) + (property "Reference" "C" (at 0.635 2.54 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Value" "C" (at 0.635 -2.54 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Footprint" "" (at 0.9652 -3.81 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "C_0_1" + (polyline + (pts + (xy -2.032 -0.762) + (xy 2.032 -0.762) + ) + (stroke (width 0.508) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy -2.032 0.762) + (xy 2.032 0.762) + ) + (stroke (width 0.508) (type default)) + (fill (type none)) + ) + ) + (symbol "C_1_1" + (pin passive line (at 0 3.81 270) (length 2.794) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 0 -3.81 90) (length 2.794) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) + (property "Reference" "D" (at 0 2.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "LED" (at 0 -2.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "LED_0_1" + (polyline + (pts + (xy -1.27 -1.27) + (xy -1.27 1.27) + ) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy -1.27 0) + (xy 1.27 0) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 1.27 -1.27) + (xy 1.27 1.27) + (xy -1.27 0) + (xy 1.27 -1.27) + ) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + ) + (symbol "LED_1_1" + (pin passive line (at -3.81 0 0) (length 2.54) + (name "K" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 3.81 0 180) (length 2.54) + (name "A" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + ) + + (symbol (lib_id "Device:R") (at -100 -100 0) (unit 1) + (in_bom no) (on_board no) (dnp yes) + (uuid 00000000-0000-0000-0000-000000000001) + (property "Reference" "_TEMPLATE_R" (at -100 -102.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "R_TEMPLATE" (at -100 -100 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at -100 -100 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at -100 -100 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid 00000000-0000-0000-0000-000000000010)) + (pin "2" (uuid 00000000-0000-0000-0000-000000000011)) + ) + + (symbol (lib_id "Device:C") (at -100 -110 0) (unit 1) + (in_bom no) (on_board no) (dnp yes) + (uuid 00000000-0000-0000-0000-000000000002) + (property "Reference" "_TEMPLATE_C" (at -100 -107.46 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "C_TEMPLATE" (at -100 -112.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at -100 -110 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at -100 -110 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid 00000000-0000-0000-0000-000000000020)) + (pin "2" (uuid 00000000-0000-0000-0000-000000000021)) + ) + + (symbol (lib_id "Device:LED") (at -100 -120 0) (unit 1) + (in_bom no) (on_board no) (dnp yes) + (uuid 00000000-0000-0000-0000-000000000003) + (property "Reference" "_TEMPLATE_D" (at -100 -117.46 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "LED_TEMPLATE" (at -100 -122.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at -100 -120 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at -100 -120 0) + (effects (font (size 1.27 1.27)) hide) + ) + (pin "1" (uuid 00000000-0000-0000-0000-000000000030)) + (pin "2" (uuid 00000000-0000-0000-0000-000000000031)) + ) + + (sheet_instances + (path "/" (page "1")) + ) +) diff --git a/python/templates/template_with_symbols_expanded.kicad_sch b/python/templates/template_with_symbols_expanded.kicad_sch index d2a7403..d551bc8 100644 --- a/python/templates/template_with_symbols_expanded.kicad_sch +++ b/python/templates/template_with_symbols_expanded.kicad_sch @@ -1,734 +1,734 @@ -(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server") - - (uuid c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f) - - (paper "A4") - - (lib_symbols - (symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes) - (property "Reference" "R" (at 2.032 0 90) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "R" (at 0 0 90) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at -1.778 0 90) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "R_0_1" - (rectangle (start -1.016 -2.54) (end 1.016 2.54) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - ) - (symbol "R_1_1" - (pin passive line (at 0 3.81 270) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 0 -3.81 90) (length 1.27) - (name "~" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes) - (property "Reference" "C" (at 0.635 2.54 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Value" "C" (at 0.635 -2.54 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Footprint" "" (at 0.9652 -3.81 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "C_0_1" - (polyline - (pts - (xy -2.032 -0.762) - (xy 2.032 -0.762) - ) - (stroke (width 0.508) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy -2.032 0.762) - (xy 2.032 0.762) - ) - (stroke (width 0.508) (type default)) - (fill (type none)) - ) - ) - (symbol "C_1_1" - (pin passive line (at 0 3.81 270) (length 2.794) - (name "~" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 0 -3.81 90) (length 2.794) - (name "~" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Device:L" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) - (property "Reference" "L" (at -1.27 0 90) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "L" (at 1.905 0 90) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "L_0_1" - (arc (start 0 -2.54) (mid 0.635 -1.905) (end 0 -1.27) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (arc (start 0 -1.27) (mid 0.635 -0.635) (end 0 0) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (arc (start 0 0) (mid 0.635 0.635) (end 0 1.27) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (arc (start 0 1.27) (mid 0.635 1.905) (end 0 2.54) - (stroke (width 0) (type default)) - (fill (type none)) - ) - ) - (symbol "L_1_1" - (pin passive line (at 0 3.81 270) (length 1.27) - (name "1" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 0 -3.81 90) (length 1.27) - (name "2" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Device:Crystal" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) - (property "Reference" "Y" (at 0 3.81 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "Crystal" (at 0 -3.81 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "Crystal_0_1" - (rectangle (start -1.143 2.54) (end 1.143 -2.54) - (stroke (width 0.3048) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy -2.54 0) - (xy -1.905 0) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy -1.905 -1.27) - (xy -1.905 1.27) - ) - (stroke (width 0.508) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 1.905 -1.27) - (xy 1.905 1.27) - ) - (stroke (width 0.508) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 2.54 0) - (xy 1.905 0) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - ) - (symbol "Crystal_1_1" - (pin passive line (at -3.81 0 0) (length 1.27) - (name "1" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 3.81 0 180) (length 1.27) - (name "2" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Device:D" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) - (property "Reference" "D" (at 0 2.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "D" (at 0 -2.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "D_0_1" - (polyline - (pts - (xy -1.27 1.27) - (xy -1.27 -1.27) - ) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 1.27 0) - (xy -1.27 0) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 1.27 1.27) - (xy 1.27 -1.27) - (xy -1.27 0) - (xy 1.27 1.27) - ) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - ) - (symbol "D_1_1" - (pin passive line (at -3.81 0 0) (length 2.54) - (name "K" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 3.81 0 180) (length 2.54) - (name "A" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) - (property "Reference" "D" (at 0 2.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "LED" (at 0 -2.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "LED_0_1" - (polyline - (pts - (xy -1.27 -1.27) - (xy -1.27 1.27) - ) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy -1.27 0) - (xy 1.27 0) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 1.27 -1.27) - (xy 1.27 1.27) - (xy -1.27 0) - (xy 1.27 -1.27) - ) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - ) - (symbol "LED_1_1" - (pin passive line (at -3.81 0 0) (length 2.54) - (name "K" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 3.81 0 180) (length 2.54) - (name "A" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Device:Q_NPN_BCE" (pin_names (offset 0) hide) (in_bom yes) (on_board yes) - (property "Reference" "Q" (at 5.08 1.27 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Value" "Q_NPN_BCE" (at 5.08 -1.27 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Footprint" "" (at 5.08 2.54 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "Q_NPN_BCE_0_1" - (polyline - (pts - (xy 0.635 0.635) - (xy 2.54 2.54) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 0.635 -0.635) - (xy 2.54 -2.54) - (xy 2.54 -2.54) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 0.635 1.905) - (xy 0.635 -1.905) - (xy 0.635 -1.905) - ) - (stroke (width 0.508) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 1.27 -1.778) - (xy 1.778 -1.27) - (xy 2.286 -2.286) - (xy 1.27 -1.778) - (xy 1.27 -1.778) - ) - (stroke (width 0) (type default)) - (fill (type outline)) - ) - (circle (center 1.27 0) (radius 2.8194) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - ) - (symbol "Q_NPN_BCE_1_1" - (pin input line (at -5.08 0 0) (length 5.715) - (name "B" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 2.54 5.08 270) (length 2.54) - (name "C" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 2.54 -5.08 90) (length 2.54) - (name "E" (effects (font (size 1.27 1.27)))) - (number "3" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Device:Q_NMOS_GSD" (pin_names (offset 0) hide) (in_bom yes) (on_board yes) - (property "Reference" "Q" (at 5.08 1.27 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Value" "Q_NMOS_GSD" (at 5.08 -1.27 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Footprint" "" (at 5.08 2.54 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "Q_NMOS_GSD_0_1" - (polyline - (pts - (xy 0.254 0) - (xy -2.54 0) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 0.254 1.905) - (xy 0.254 -1.905) - ) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 0.762 -1.27) - (xy 0.762 -2.286) - ) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 0.762 0.508) - (xy 0.762 -0.508) - ) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 0.762 2.286) - (xy 0.762 1.27) - ) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 2.54 2.54) - (xy 2.54 1.778) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 2.54 -2.54) - (xy 2.54 0) - (xy 0.762 0) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 0.762 -1.778) - (xy 3.302 -1.778) - (xy 3.302 1.778) - (xy 0.762 1.778) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 1.016 0) - (xy 2.032 0.381) - (xy 2.032 -0.381) - (xy 1.016 0) - ) - (stroke (width 0) (type default)) - (fill (type outline)) - ) - (polyline - (pts - (xy 2.794 0.508) - (xy 2.921 0.381) - (xy 3.683 0.381) - (xy 3.81 0.254) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 3.302 0.381) - (xy 2.921 -0.254) - (xy 3.683 -0.254) - (xy 3.302 0.381) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (circle (center 1.651 0) (radius 2.794) - (stroke (width 0.254) (type default)) - (fill (type none)) - ) - (circle (center 2.54 -1.778) (radius 0.254) - (stroke (width 0) (type default)) - (fill (type outline)) - ) - (circle (center 2.54 1.778) (radius 0.254) - (stroke (width 0) (type default)) - (fill (type outline)) - ) - ) - (symbol "Q_NMOS_GSD_1_1" - (pin input line (at -5.08 0 0) (length 2.54) - (name "G" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 2.54 -5.08 90) (length 2.54) - (name "S" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 2.54 5.08 270) (length 2.54) - (name "D" (effects (font (size 1.27 1.27)))) - (number "3" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Amplifier_Operational:LM358" (pin_names (offset 0.127)) (in_bom yes) (on_board yes) - (property "Reference" "U" (at 0 5.08 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Value" "LM358" (at 0 -5.08 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Footprint" "" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "LM358_0_1" - (polyline - (pts - (xy -5.08 5.08) - (xy 5.08 0) - (xy -5.08 -5.08) - (xy -5.08 5.08) - ) - (stroke (width 0.254) (type default)) - (fill (type background)) - ) - ) - (symbol "LM358_1_1" - (pin input line (at -7.62 2.54 0) (length 2.54) - (name "~" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin input line (at -7.62 -2.54 0) (length 2.54) - (name "+" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - (pin output line (at 7.62 0 180) (length 2.54) - (name "~" (effects (font (size 1.27 1.27)))) - (number "3" (effects (font (size 1.27 1.27)))) - ) - (pin power_in line (at -2.54 -7.62 90) (length 3.81) - (name "V-" (effects (font (size 1.27 1.27)))) - (number "4" (effects (font (size 1.27 1.27)))) - ) - (pin power_in line (at -2.54 7.62 270) (length 3.81) - (name "V+" (effects (font (size 1.27 1.27)))) - (number "8" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Connector_Generic:Conn_01x02" (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) - (property "Reference" "J" (at 0 2.54 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "Conn_01x02" (at 0 -5.08 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "Conn_01x02_1_1" - (rectangle (start -1.27 -2.413) (end 0 -2.667) - (stroke (width 0.1524) (type default)) - (fill (type none)) - ) - (rectangle (start -1.27 0.127) (end 0 -0.127) - (stroke (width 0.1524) (type default)) - (fill (type none)) - ) - (rectangle (start -1.27 1.27) (end 1.27 -3.81) - (stroke (width 0.254) (type default)) - (fill (type background)) - ) - (pin passive line (at -5.08 0 0) (length 3.81) - (name "Pin_1" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at -5.08 -2.54 0) (length 3.81) - (name "Pin_2" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Connector_Generic:Conn_01x04" (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) - (property "Reference" "J" (at 0 5.08 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "Conn_01x04" (at 0 -7.62 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 0 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "Conn_01x04_1_1" - (rectangle (start -1.27 -4.953) (end 0 -5.207) - (stroke (width 0.1524) (type default)) - (fill (type none)) - ) - (rectangle (start -1.27 -2.413) (end 0 -2.667) - (stroke (width 0.1524) (type default)) - (fill (type none)) - ) - (rectangle (start -1.27 0.127) (end 0 -0.127) - (stroke (width 0.1524) (type default)) - (fill (type none)) - ) - (rectangle (start -1.27 2.667) (end 0 2.413) - (stroke (width 0.1524) (type default)) - (fill (type none)) - ) - (rectangle (start -1.27 3.81) (end 1.27 -6.35) - (stroke (width 0.254) (type default)) - (fill (type background)) - ) - (pin passive line (at -5.08 2.54 0) (length 3.81) - (name "Pin_1" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at -5.08 0 0) (length 3.81) - (name "Pin_2" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at -5.08 -2.54 0) (length 3.81) - (name "Pin_3" (effects (font (size 1.27 1.27)))) - (number "3" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at -5.08 -5.08 0) (length 3.81) - (name "Pin_4" (effects (font (size 1.27 1.27)))) - (number "4" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Regulator_Linear:AMS1117-3.3" (pin_names (offset 0.254)) (in_bom yes) (on_board yes) - (property "Reference" "U" (at -3.81 3.175 0) - (effects (font (size 1.27 1.27))) - ) - (property "Value" "AMS1117-3.3" (at 0 3.175 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Footprint" "" (at 0 5.08 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "" (at 2.54 -6.35 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "AMS1117-3.3_0_1" - (rectangle (start -5.08 1.905) (end 5.08 -5.08) - (stroke (width 0.254) (type default)) - (fill (type background)) - ) - ) - (symbol "AMS1117-3.3_1_1" - (pin power_in line (at 0 -7.62 90) (length 2.54) - (name "GND" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin power_out line (at 7.62 0 180) (length 2.54) - (name "VO" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - (pin power_in line (at -7.62 0 0) (length 2.54) - (name "VI" (effects (font (size 1.27 1.27)))) - (number "3" (effects (font (size 1.27 1.27)))) - ) - ) - ) - (symbol "Switch:SW_Push" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) - (property "Reference" "SW" (at 1.27 2.54 0) - (effects (font (size 1.27 1.27)) (justify left)) - ) - (property "Value" "SW_Push" (at 0 -1.524 0) - (effects (font (size 1.27 1.27))) - ) - (property "Footprint" "" (at 0 5.08 0) - (effects (font (size 1.27 1.27)) hide) - ) - (property "Datasheet" "~" (at 0 5.08 0) - (effects (font (size 1.27 1.27)) hide) - ) - (symbol "SW_Push_0_1" - (circle (center -2.032 0) (radius 0.508) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 0 1.27) - (xy 0 3.048) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (polyline - (pts - (xy 2.54 1.27) - (xy -2.54 1.27) - ) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (circle (center 2.032 0) (radius 0.508) - (stroke (width 0) (type default)) - (fill (type none)) - ) - (pin passive line (at -5.08 0 0) (length 2.54) - (name "1" (effects (font (size 1.27 1.27)))) - (number "1" (effects (font (size 1.27 1.27)))) - ) - (pin passive line (at 5.08 0 180) (length 2.54) - (name "2" (effects (font (size 1.27 1.27)))) - (number "2" (effects (font (size 1.27 1.27)))) - ) - ) - ) - ) - - - - - - - - - - - - - - - (sheet_instances - (path "/" (page "1")) - ) -) +(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server") + + (uuid c3d4e5f6-a7b8-4c9d-0e1f-2a3b4c5d6e7f) + + (paper "A4") + + (lib_symbols + (symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes) + (property "Reference" "R" (at 2.032 0 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "R" (at 0 0 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at -1.778 0 90) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "R_0_1" + (rectangle (start -1.016 -2.54) (end 1.016 2.54) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + ) + (symbol "R_1_1" + (pin passive line (at 0 3.81 270) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 0 -3.81 90) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes) + (property "Reference" "C" (at 0.635 2.54 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Value" "C" (at 0.635 -2.54 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Footprint" "" (at 0.9652 -3.81 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "C_0_1" + (polyline + (pts + (xy -2.032 -0.762) + (xy 2.032 -0.762) + ) + (stroke (width 0.508) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy -2.032 0.762) + (xy 2.032 0.762) + ) + (stroke (width 0.508) (type default)) + (fill (type none)) + ) + ) + (symbol "C_1_1" + (pin passive line (at 0 3.81 270) (length 2.794) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 0 -3.81 90) (length 2.794) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Device:L" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) + (property "Reference" "L" (at -1.27 0 90) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "L" (at 1.905 0 90) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "L_0_1" + (arc (start 0 -2.54) (mid 0.635 -1.905) (end 0 -1.27) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (arc (start 0 -1.27) (mid 0.635 -0.635) (end 0 0) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (arc (start 0 0) (mid 0.635 0.635) (end 0 1.27) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (arc (start 0 1.27) (mid 0.635 1.905) (end 0 2.54) + (stroke (width 0) (type default)) + (fill (type none)) + ) + ) + (symbol "L_1_1" + (pin passive line (at 0 3.81 270) (length 1.27) + (name "1" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 0 -3.81 90) (length 1.27) + (name "2" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Device:Crystal" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) + (property "Reference" "Y" (at 0 3.81 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "Crystal" (at 0 -3.81 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "Crystal_0_1" + (rectangle (start -1.143 2.54) (end 1.143 -2.54) + (stroke (width 0.3048) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy -2.54 0) + (xy -1.905 0) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy -1.905 -1.27) + (xy -1.905 1.27) + ) + (stroke (width 0.508) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 1.905 -1.27) + (xy 1.905 1.27) + ) + (stroke (width 0.508) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 2.54 0) + (xy 1.905 0) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + ) + (symbol "Crystal_1_1" + (pin passive line (at -3.81 0 0) (length 1.27) + (name "1" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 3.81 0 180) (length 1.27) + (name "2" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Device:D" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) + (property "Reference" "D" (at 0 2.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "D" (at 0 -2.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "D_0_1" + (polyline + (pts + (xy -1.27 1.27) + (xy -1.27 -1.27) + ) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 1.27 0) + (xy -1.27 0) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 1.27 1.27) + (xy 1.27 -1.27) + (xy -1.27 0) + (xy 1.27 1.27) + ) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + ) + (symbol "D_1_1" + (pin passive line (at -3.81 0 0) (length 2.54) + (name "K" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 3.81 0 180) (length 2.54) + (name "A" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) + (property "Reference" "D" (at 0 2.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "LED" (at 0 -2.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "LED_0_1" + (polyline + (pts + (xy -1.27 -1.27) + (xy -1.27 1.27) + ) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy -1.27 0) + (xy 1.27 0) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 1.27 -1.27) + (xy 1.27 1.27) + (xy -1.27 0) + (xy 1.27 -1.27) + ) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + ) + (symbol "LED_1_1" + (pin passive line (at -3.81 0 0) (length 2.54) + (name "K" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 3.81 0 180) (length 2.54) + (name "A" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Device:Q_NPN_BCE" (pin_names (offset 0) hide) (in_bom yes) (on_board yes) + (property "Reference" "Q" (at 5.08 1.27 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Value" "Q_NPN_BCE" (at 5.08 -1.27 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Footprint" "" (at 5.08 2.54 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "Q_NPN_BCE_0_1" + (polyline + (pts + (xy 0.635 0.635) + (xy 2.54 2.54) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 0.635 -0.635) + (xy 2.54 -2.54) + (xy 2.54 -2.54) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 0.635 1.905) + (xy 0.635 -1.905) + (xy 0.635 -1.905) + ) + (stroke (width 0.508) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 1.27 -1.778) + (xy 1.778 -1.27) + (xy 2.286 -2.286) + (xy 1.27 -1.778) + (xy 1.27 -1.778) + ) + (stroke (width 0) (type default)) + (fill (type outline)) + ) + (circle (center 1.27 0) (radius 2.8194) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + ) + (symbol "Q_NPN_BCE_1_1" + (pin input line (at -5.08 0 0) (length 5.715) + (name "B" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 2.54 5.08 270) (length 2.54) + (name "C" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 2.54 -5.08 90) (length 2.54) + (name "E" (effects (font (size 1.27 1.27)))) + (number "3" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Device:Q_NMOS_GSD" (pin_names (offset 0) hide) (in_bom yes) (on_board yes) + (property "Reference" "Q" (at 5.08 1.27 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Value" "Q_NMOS_GSD" (at 5.08 -1.27 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Footprint" "" (at 5.08 2.54 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "Q_NMOS_GSD_0_1" + (polyline + (pts + (xy 0.254 0) + (xy -2.54 0) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 0.254 1.905) + (xy 0.254 -1.905) + ) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 0.762 -1.27) + (xy 0.762 -2.286) + ) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 0.762 0.508) + (xy 0.762 -0.508) + ) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 0.762 2.286) + (xy 0.762 1.27) + ) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 2.54 2.54) + (xy 2.54 1.778) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 2.54 -2.54) + (xy 2.54 0) + (xy 0.762 0) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 0.762 -1.778) + (xy 3.302 -1.778) + (xy 3.302 1.778) + (xy 0.762 1.778) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 1.016 0) + (xy 2.032 0.381) + (xy 2.032 -0.381) + (xy 1.016 0) + ) + (stroke (width 0) (type default)) + (fill (type outline)) + ) + (polyline + (pts + (xy 2.794 0.508) + (xy 2.921 0.381) + (xy 3.683 0.381) + (xy 3.81 0.254) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 3.302 0.381) + (xy 2.921 -0.254) + (xy 3.683 -0.254) + (xy 3.302 0.381) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (circle (center 1.651 0) (radius 2.794) + (stroke (width 0.254) (type default)) + (fill (type none)) + ) + (circle (center 2.54 -1.778) (radius 0.254) + (stroke (width 0) (type default)) + (fill (type outline)) + ) + (circle (center 2.54 1.778) (radius 0.254) + (stroke (width 0) (type default)) + (fill (type outline)) + ) + ) + (symbol "Q_NMOS_GSD_1_1" + (pin input line (at -5.08 0 0) (length 2.54) + (name "G" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 2.54 -5.08 90) (length 2.54) + (name "S" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 2.54 5.08 270) (length 2.54) + (name "D" (effects (font (size 1.27 1.27)))) + (number "3" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Amplifier_Operational:LM358" (pin_names (offset 0.127)) (in_bom yes) (on_board yes) + (property "Reference" "U" (at 0 5.08 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Value" "LM358" (at 0 -5.08 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Footprint" "" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "LM358_0_1" + (polyline + (pts + (xy -5.08 5.08) + (xy 5.08 0) + (xy -5.08 -5.08) + (xy -5.08 5.08) + ) + (stroke (width 0.254) (type default)) + (fill (type background)) + ) + ) + (symbol "LM358_1_1" + (pin input line (at -7.62 2.54 0) (length 2.54) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin input line (at -7.62 -2.54 0) (length 2.54) + (name "+" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + (pin output line (at 7.62 0 180) (length 2.54) + (name "~" (effects (font (size 1.27 1.27)))) + (number "3" (effects (font (size 1.27 1.27)))) + ) + (pin power_in line (at -2.54 -7.62 90) (length 3.81) + (name "V-" (effects (font (size 1.27 1.27)))) + (number "4" (effects (font (size 1.27 1.27)))) + ) + (pin power_in line (at -2.54 7.62 270) (length 3.81) + (name "V+" (effects (font (size 1.27 1.27)))) + (number "8" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Connector_Generic:Conn_01x02" (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) + (property "Reference" "J" (at 0 2.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "Conn_01x02" (at 0 -5.08 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "Conn_01x02_1_1" + (rectangle (start -1.27 -2.413) (end 0 -2.667) + (stroke (width 0.1524) (type default)) + (fill (type none)) + ) + (rectangle (start -1.27 0.127) (end 0 -0.127) + (stroke (width 0.1524) (type default)) + (fill (type none)) + ) + (rectangle (start -1.27 1.27) (end 1.27 -3.81) + (stroke (width 0.254) (type default)) + (fill (type background)) + ) + (pin passive line (at -5.08 0 0) (length 3.81) + (name "Pin_1" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at -5.08 -2.54 0) (length 3.81) + (name "Pin_2" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Connector_Generic:Conn_01x04" (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) + (property "Reference" "J" (at 0 5.08 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "Conn_01x04" (at 0 -7.62 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 0 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "Conn_01x04_1_1" + (rectangle (start -1.27 -4.953) (end 0 -5.207) + (stroke (width 0.1524) (type default)) + (fill (type none)) + ) + (rectangle (start -1.27 -2.413) (end 0 -2.667) + (stroke (width 0.1524) (type default)) + (fill (type none)) + ) + (rectangle (start -1.27 0.127) (end 0 -0.127) + (stroke (width 0.1524) (type default)) + (fill (type none)) + ) + (rectangle (start -1.27 2.667) (end 0 2.413) + (stroke (width 0.1524) (type default)) + (fill (type none)) + ) + (rectangle (start -1.27 3.81) (end 1.27 -6.35) + (stroke (width 0.254) (type default)) + (fill (type background)) + ) + (pin passive line (at -5.08 2.54 0) (length 3.81) + (name "Pin_1" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at -5.08 0 0) (length 3.81) + (name "Pin_2" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at -5.08 -2.54 0) (length 3.81) + (name "Pin_3" (effects (font (size 1.27 1.27)))) + (number "3" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at -5.08 -5.08 0) (length 3.81) + (name "Pin_4" (effects (font (size 1.27 1.27)))) + (number "4" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Regulator_Linear:AMS1117-3.3" (pin_names (offset 0.254)) (in_bom yes) (on_board yes) + (property "Reference" "U" (at -3.81 3.175 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "AMS1117-3.3" (at 0 3.175 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Footprint" "" (at 0 5.08 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "" (at 2.54 -6.35 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "AMS1117-3.3_0_1" + (rectangle (start -5.08 1.905) (end 5.08 -5.08) + (stroke (width 0.254) (type default)) + (fill (type background)) + ) + ) + (symbol "AMS1117-3.3_1_1" + (pin power_in line (at 0 -7.62 90) (length 2.54) + (name "GND" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin power_out line (at 7.62 0 180) (length 2.54) + (name "VO" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + (pin power_in line (at -7.62 0 0) (length 2.54) + (name "VI" (effects (font (size 1.27 1.27)))) + (number "3" (effects (font (size 1.27 1.27)))) + ) + ) + ) + (symbol "Switch:SW_Push" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes) + (property "Reference" "SW" (at 1.27 2.54 0) + (effects (font (size 1.27 1.27)) (justify left)) + ) + (property "Value" "SW_Push" (at 0 -1.524 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "" (at 0 5.08 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 0 5.08 0) + (effects (font (size 1.27 1.27)) hide) + ) + (symbol "SW_Push_0_1" + (circle (center -2.032 0) (radius 0.508) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 0 1.27) + (xy 0 3.048) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (polyline + (pts + (xy 2.54 1.27) + (xy -2.54 1.27) + ) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (circle (center 2.032 0) (radius 0.508) + (stroke (width 0) (type default)) + (fill (type none)) + ) + (pin passive line (at -5.08 0 0) (length 2.54) + (name "1" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27)))) + ) + (pin passive line (at 5.08 0 180) (length 2.54) + (name "2" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27)))) + ) + ) + ) + ) + + + + + + + + + + + + + + + (sheet_instances + (path "/" (page "1")) + ) +) diff --git a/python/test_ipc_backend.py b/python/test_ipc_backend.py index 2eb5c35..4c92ca4 100644 --- a/python/test_ipc_backend.py +++ b/python/test_ipc_backend.py @@ -1,319 +1,319 @@ -#!/usr/bin/env python3 -""" -Test script for KiCAD IPC Backend - -This script tests the real-time UI synchronization capabilities -of the IPC backend. Run this while KiCAD is open with a board. - -Prerequisites: -1. KiCAD 9.0+ must be running -2. IPC API must be enabled: Preferences > Plugins > Enable IPC API Server -3. A board should be open in the PCB editor - -Usage: - ./venv/bin/python python/test_ipc_backend.py -""" - -import os -import sys -from typing import Any, Optional - -# Add parent directory to path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -import logging - -# Set up logging -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" -) -logger = logging.getLogger(__name__) - - -def test_connection() -> Optional[Any]: - """Test basic IPC connection to KiCAD.""" - print("\n" + "=" * 60) - print("TEST 1: IPC Connection") - print("=" * 60) - - try: - from kicad_api.ipc_backend import IPCBackend - - backend = IPCBackend() - print("✓ IPCBackend created") - - if backend.connect(): - print(f"✓ Connected to KiCAD via IPC") - print(f" Version: {backend.get_version()}") - return backend - else: - print("✗ Failed to connect to KiCAD") - return None - - except ImportError as e: - print(f"✗ kicad-python not installed: {e}") - print(" Install with: pip install kicad-python") - return None - except Exception as e: - print(f"✗ Connection failed: {e}") - print("\nMake sure:") - print(" 1. KiCAD is running") - print(" 2. IPC API is enabled (Preferences > Plugins > Enable IPC API Server)") - print(" 3. A board is open in the PCB editor") - return None - - -def test_board_access(backend: Any) -> Optional[Any]: - """Test board access and component listing.""" - print("\n" + "=" * 60) - print("TEST 2: Board Access") - print("=" * 60) - - try: - board_api = backend.get_board() - print("✓ Got board API") - - # List components - components = board_api.list_components() - print(f"✓ Found {len(components)} components on board") - - if components: - print("\n First 5 components:") - for comp in components[:5]: - ref = comp.get("reference", "N/A") - val = comp.get("value", "N/A") - pos = comp.get("position", {}) - x = pos.get("x", 0) - y = pos.get("y", 0) - print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm") - - return board_api - - except Exception as e: - print(f"✗ Failed to access board: {e}") - return None - - -def test_board_info(board_api: Any) -> bool: - """Test getting board information.""" - print("\n" + "=" * 60) - print("TEST 3: Board Information") - print("=" * 60) - - try: - # Get board size - size = board_api.get_size() - print(f"✓ Board size: {size.get('width', 0):.2f} x {size.get('height', 0):.2f} mm") - - # Get enabled layers - try: - layers = board_api.get_enabled_layers() - print(f"✓ Enabled layers: {len(layers)}") - if layers: - print(f" Layers: {', '.join(layers[:5])}...") - except Exception as e: - print(f" (Layer info not available: {e})") - - # Get nets - nets = board_api.get_nets() - print(f"✓ Found {len(nets)} nets") - if nets: - print(f" First 5 nets: {', '.join([n.get('name', '') for n in nets[:5]])}") - - # Get tracks - tracks = board_api.get_tracks() - print(f"✓ Found {len(tracks)} tracks") - - # Get vias - vias = board_api.get_vias() - print(f"✓ Found {len(vias)} vias") - - return True - - except Exception as e: - print(f"✗ Failed to get board info: {e}") - return False - - -def test_realtime_track(board_api: Any, interactive: bool = False) -> bool: - """Test adding a track in real-time (appears immediately in KiCAD UI).""" - print("\n" + "=" * 60) - print("TEST 4: Real-time Track Addition") - print("=" * 60) - - print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.") - print("Watch the KiCAD window!") - - if interactive: - response = input("\nProceed with adding a test track? [y/N]: ").strip().lower() - if response != "y": - print("Skipped track test") - return False - - try: - # Add a track - success = board_api.add_track( - start_x=100.0, start_y=100.0, end_x=120.0, end_y=100.0, width=0.25, layer="F.Cu" - ) - - if success: - print("✓ Track added! Check the KiCAD window - it should appear at (100, 100) mm") - print(" Track: (100, 100) -> (120, 100) mm, width 0.25mm on F.Cu") - else: - print("✗ Failed to add track") - - return success - - except Exception as e: - print(f"✗ Error adding track: {e}") - return False - - -def test_realtime_via(board_api: Any, interactive: bool = False) -> bool: - """Test adding a via in real-time (appears immediately in KiCAD UI).""" - print("\n" + "=" * 60) - print("TEST 5: Real-time Via Addition") - print("=" * 60) - - print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.") - print("Watch the KiCAD window!") - - if interactive: - response = input("\nProceed with adding a test via? [y/N]: ").strip().lower() - if response != "y": - print("Skipped via test") - return False - - try: - # Add a via - success = board_api.add_via(x=120.0, y=100.0, diameter=0.8, drill=0.4, via_type="through") - - if success: - print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm") - print(" Via: diameter 0.8mm, drill 0.4mm") - else: - print("✗ Failed to add via") - - return success - - except Exception as e: - print(f"✗ Error adding via: {e}") - return False - - -def test_realtime_text(board_api: Any, interactive: bool = False) -> bool: - """Test adding text in real-time.""" - print("\n" + "=" * 60) - print("TEST 6: Real-time Text Addition") - print("=" * 60) - - print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.") - - if interactive: - response = input("\nProceed with adding test text? [y/N]: ").strip().lower() - if response != "y": - print("Skipped text test") - return False - - try: - success = board_api.add_text(text="MCP Test", x=100.0, y=95.0, layer="F.SilkS", size=1.0) - - if success: - print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm") - else: - print("✗ Failed to add text") - - return success - - except Exception as e: - print(f"✗ Error adding text: {e}") - return False - - -def test_selection(board_api: Any, interactive: bool = False) -> bool: - """Test getting the current selection from KiCAD UI.""" - print("\n" + "=" * 60) - print("TEST 7: UI Selection") - print("=" * 60) - - if interactive: - print("\nSelect some items in KiCAD, then press Enter...") - input() - else: - print("\nReading current selection...") - - try: - selection = board_api.get_selection() - print(f"✓ Found {len(selection)} selected items") - - for item in selection[:10]: - print(f" - {item.get('type', 'Unknown')} (ID: {item.get('id', 'N/A')})") - - return True - - except Exception as e: - print(f"✗ Failed to get selection: {e}") - return False - - -def run_all_tests(interactive: bool = False) -> bool: - """Run all IPC backend tests.""" - print("\n" + "=" * 60) - print("KiCAD IPC Backend Test Suite") - print("=" * 60) - print("\nThis script tests real-time communication with KiCAD via IPC API.") - print("Make sure KiCAD is running with a board open.\n") - - # Test connection - backend = test_connection() - if not backend: - print("\n" + "=" * 60) - print("TESTS FAILED: Could not connect to KiCAD") - print("=" * 60) - return False - - # Test board access - board_api = test_board_access(backend) - if not board_api: - print("\n" + "=" * 60) - print("TESTS FAILED: Could not access board") - print("=" * 60) - return False - - # Test board info - test_board_info(board_api) - - # Test real-time modifications - test_realtime_track(board_api, interactive) - test_realtime_via(board_api, interactive) - test_realtime_text(board_api, interactive) - - # Test selection - test_selection(board_api, interactive) - - print("\n" + "=" * 60) - print("TESTS COMPLETE") - print("=" * 60) - print("\nThe IPC backend is working! Changes appear in real-time.") - print("No manual reload required - this is the power of the IPC API!") - - # Cleanup - backend.disconnect() - - return True - - -if __name__ == "__main__": - import argparse - - parser = argparse.ArgumentParser(description="Test KiCAD IPC Backend") - parser.add_argument( - "-i", - "--interactive", - action="store_true", - help="Run in interactive mode (prompts before modifications)", - ) - args = parser.parse_args() - - success = run_all_tests(interactive=args.interactive) - sys.exit(0 if success else 1) +#!/usr/bin/env python3 +""" +Test script for KiCAD IPC Backend + +This script tests the real-time UI synchronization capabilities +of the IPC backend. Run this while KiCAD is open with a board. + +Prerequisites: +1. KiCAD 9.0+ must be running +2. IPC API must be enabled: Preferences > Plugins > Enable IPC API Server +3. A board should be open in the PCB editor + +Usage: + ./venv/bin/python python/test_ipc_backend.py +""" + +import os +import sys +from typing import Any, Optional + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import logging + +# Set up logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +def test_connection() -> Optional[Any]: + """Test basic IPC connection to KiCAD.""" + print("\n" + "=" * 60) + print("TEST 1: IPC Connection") + print("=" * 60) + + try: + from kicad_api.ipc_backend import IPCBackend + + backend = IPCBackend() + print("✓ IPCBackend created") + + if backend.connect(): + print(f"✓ Connected to KiCAD via IPC") + print(f" Version: {backend.get_version()}") + return backend + else: + print("✗ Failed to connect to KiCAD") + return None + + except ImportError as e: + print(f"✗ kicad-python not installed: {e}") + print(" Install with: pip install kicad-python") + return None + except Exception as e: + print(f"✗ Connection failed: {e}") + print("\nMake sure:") + print(" 1. KiCAD is running") + print(" 2. IPC API is enabled (Preferences > Plugins > Enable IPC API Server)") + print(" 3. A board is open in the PCB editor") + return None + + +def test_board_access(backend: Any) -> Optional[Any]: + """Test board access and component listing.""" + print("\n" + "=" * 60) + print("TEST 2: Board Access") + print("=" * 60) + + try: + board_api = backend.get_board() + print("✓ Got board API") + + # List components + components = board_api.list_components() + print(f"✓ Found {len(components)} components on board") + + if components: + print("\n First 5 components:") + for comp in components[:5]: + ref = comp.get("reference", "N/A") + val = comp.get("value", "N/A") + pos = comp.get("position", {}) + x = pos.get("x", 0) + y = pos.get("y", 0) + print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm") + + return board_api + + except Exception as e: + print(f"✗ Failed to access board: {e}") + return None + + +def test_board_info(board_api: Any) -> bool: + """Test getting board information.""" + print("\n" + "=" * 60) + print("TEST 3: Board Information") + print("=" * 60) + + try: + # Get board size + size = board_api.get_size() + print(f"✓ Board size: {size.get('width', 0):.2f} x {size.get('height', 0):.2f} mm") + + # Get enabled layers + try: + layers = board_api.get_enabled_layers() + print(f"✓ Enabled layers: {len(layers)}") + if layers: + print(f" Layers: {', '.join(layers[:5])}...") + except Exception as e: + print(f" (Layer info not available: {e})") + + # Get nets + nets = board_api.get_nets() + print(f"✓ Found {len(nets)} nets") + if nets: + print(f" First 5 nets: {', '.join([n.get('name', '') for n in nets[:5]])}") + + # Get tracks + tracks = board_api.get_tracks() + print(f"✓ Found {len(tracks)} tracks") + + # Get vias + vias = board_api.get_vias() + print(f"✓ Found {len(vias)} vias") + + return True + + except Exception as e: + print(f"✗ Failed to get board info: {e}") + return False + + +def test_realtime_track(board_api: Any, interactive: bool = False) -> bool: + """Test adding a track in real-time (appears immediately in KiCAD UI).""" + print("\n" + "=" * 60) + print("TEST 4: Real-time Track Addition") + print("=" * 60) + + print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.") + print("Watch the KiCAD window!") + + if interactive: + response = input("\nProceed with adding a test track? [y/N]: ").strip().lower() + if response != "y": + print("Skipped track test") + return False + + try: + # Add a track + success = board_api.add_track( + start_x=100.0, start_y=100.0, end_x=120.0, end_y=100.0, width=0.25, layer="F.Cu" + ) + + if success: + print("✓ Track added! Check the KiCAD window - it should appear at (100, 100) mm") + print(" Track: (100, 100) -> (120, 100) mm, width 0.25mm on F.Cu") + else: + print("✗ Failed to add track") + + return success + + except Exception as e: + print(f"✗ Error adding track: {e}") + return False + + +def test_realtime_via(board_api: Any, interactive: bool = False) -> bool: + """Test adding a via in real-time (appears immediately in KiCAD UI).""" + print("\n" + "=" * 60) + print("TEST 5: Real-time Via Addition") + print("=" * 60) + + print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.") + print("Watch the KiCAD window!") + + if interactive: + response = input("\nProceed with adding a test via? [y/N]: ").strip().lower() + if response != "y": + print("Skipped via test") + return False + + try: + # Add a via + success = board_api.add_via(x=120.0, y=100.0, diameter=0.8, drill=0.4, via_type="through") + + if success: + print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm") + print(" Via: diameter 0.8mm, drill 0.4mm") + else: + print("✗ Failed to add via") + + return success + + except Exception as e: + print(f"✗ Error adding via: {e}") + return False + + +def test_realtime_text(board_api: Any, interactive: bool = False) -> bool: + """Test adding text in real-time.""" + print("\n" + "=" * 60) + print("TEST 6: Real-time Text Addition") + print("=" * 60) + + print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.") + + if interactive: + response = input("\nProceed with adding test text? [y/N]: ").strip().lower() + if response != "y": + print("Skipped text test") + return False + + try: + success = board_api.add_text(text="MCP Test", x=100.0, y=95.0, layer="F.SilkS", size=1.0) + + if success: + print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm") + else: + print("✗ Failed to add text") + + return success + + except Exception as e: + print(f"✗ Error adding text: {e}") + return False + + +def test_selection(board_api: Any, interactive: bool = False) -> bool: + """Test getting the current selection from KiCAD UI.""" + print("\n" + "=" * 60) + print("TEST 7: UI Selection") + print("=" * 60) + + if interactive: + print("\nSelect some items in KiCAD, then press Enter...") + input() + else: + print("\nReading current selection...") + + try: + selection = board_api.get_selection() + print(f"✓ Found {len(selection)} selected items") + + for item in selection[:10]: + print(f" - {item.get('type', 'Unknown')} (ID: {item.get('id', 'N/A')})") + + return True + + except Exception as e: + print(f"✗ Failed to get selection: {e}") + return False + + +def run_all_tests(interactive: bool = False) -> bool: + """Run all IPC backend tests.""" + print("\n" + "=" * 60) + print("KiCAD IPC Backend Test Suite") + print("=" * 60) + print("\nThis script tests real-time communication with KiCAD via IPC API.") + print("Make sure KiCAD is running with a board open.\n") + + # Test connection + backend = test_connection() + if not backend: + print("\n" + "=" * 60) + print("TESTS FAILED: Could not connect to KiCAD") + print("=" * 60) + return False + + # Test board access + board_api = test_board_access(backend) + if not board_api: + print("\n" + "=" * 60) + print("TESTS FAILED: Could not access board") + print("=" * 60) + return False + + # Test board info + test_board_info(board_api) + + # Test real-time modifications + test_realtime_track(board_api, interactive) + test_realtime_via(board_api, interactive) + test_realtime_text(board_api, interactive) + + # Test selection + test_selection(board_api, interactive) + + print("\n" + "=" * 60) + print("TESTS COMPLETE") + print("=" * 60) + print("\nThe IPC backend is working! Changes appear in real-time.") + print("No manual reload required - this is the power of the IPC API!") + + # Cleanup + backend.disconnect() + + return True + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Test KiCAD IPC Backend") + parser.add_argument( + "-i", + "--interactive", + action="store_true", + help="Run in interactive mode (prompts before modifications)", + ) + args = parser.parse_args() + + success = run_all_tests(interactive=args.interactive) + sys.exit(0 if success else 1) diff --git a/python/utils/__init__.py b/python/utils/__init__.py index 98ecb61..95c4e40 100644 --- a/python/utils/__init__.py +++ b/python/utils/__init__.py @@ -1 +1 @@ -"""Utility modules for KiCAD MCP Server""" +"""Utility modules for KiCAD MCP Server""" diff --git a/python/utils/kicad_process.py b/python/utils/kicad_process.py index 4642c33..798bd85 100644 --- a/python/utils/kicad_process.py +++ b/python/utils/kicad_process.py @@ -1,349 +1,349 @@ -""" -KiCAD Process Management Utilities - -Detects if KiCAD is running and provides auto-launch functionality. -""" - -import ctypes -import logging -import os -import platform -import subprocess -import time -from ctypes import wintypes -from pathlib import Path -from typing import List, Optional - -logger = logging.getLogger(__name__) - - -class KiCADProcessManager: - """Manages KiCAD process detection and launching""" - - @staticmethod - def _windows_list_processes() -> List[dict]: - """List running processes on Windows using Toolhelp API.""" - processes: List[dict] = [] - try: - TH32CS_SNAPPROCESS = 0x00000002 - try: - ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined] - except AttributeError: - ulong_ptr = ( - ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong - ) - - class PROCESSENTRY32W(ctypes.Structure): - _fields_ = [ - ("dwSize", wintypes.DWORD), - ("cntUsage", wintypes.DWORD), - ("th32ProcessID", wintypes.DWORD), - ("th32DefaultHeapID", ulong_ptr), - ("th32ModuleID", wintypes.DWORD), - ("cntThreads", wintypes.DWORD), - ("th32ParentProcessID", wintypes.DWORD), - ("pcPriClassBase", wintypes.LONG), - ("dwFlags", wintypes.DWORD), - ("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH), - ] - - CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot - Process32FirstW = ctypes.windll.kernel32.Process32FirstW - Process32NextW = ctypes.windll.kernel32.Process32NextW - CloseHandle = ctypes.windll.kernel32.CloseHandle - - snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) - if snapshot == wintypes.HANDLE(-1).value: - return processes - - entry = PROCESSENTRY32W() - entry.dwSize = ctypes.sizeof(PROCESSENTRY32W) - - if Process32FirstW(snapshot, ctypes.byref(entry)): - while True: - processes.append( - { - "pid": str(entry.th32ProcessID), - "name": entry.szExeFile, - "command": entry.szExeFile, - } - ) - if not Process32NextW(snapshot, ctypes.byref(entry)): - break - - CloseHandle(snapshot) - except Exception as e: - logger.error(f"Error listing Windows processes: {e}") - - return processes - - @staticmethod - def is_running() -> bool: - """ - Check if KiCAD is currently running - - Returns: - True if KiCAD process found, False otherwise - """ - system = platform.system() - - try: - if system == "Linux": - # Check for actual pcbnew/kicad binaries (not python scripts) - # Use exact process name matching to avoid matching our own kicad_interface.py - result = subprocess.run( - ["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True - ) - if result.returncode == 0: - return True - # Also check with -f for full path matching, but exclude our script - result = subprocess.run( - ["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True - ) - # Double-check it's not our own process - if result.returncode == 0: - pids = result.stdout.strip().split("\n") - for pid in pids: - try: - cmdline = subprocess.run( - ["ps", "-p", pid, "-o", "command="], capture_output=True, text=True - ) - if "kicad_interface.py" not in cmdline.stdout: - return True - except: - pass - return False - - elif system == "Darwin": # macOS - result = subprocess.run( - ["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True - ) - return result.returncode == 0 - - elif system == "Windows": - processes = KiCADProcessManager._windows_list_processes() - for proc in processes: - name = (proc.get("name") or "").lower() - if name in ("pcbnew.exe", "kicad.exe"): - return True - return False - - else: - logger.warning(f"Process detection not implemented for {system}") - return False - - except Exception as e: - logger.error(f"Error checking if KiCAD is running: {e}") - return False - - @staticmethod - def get_executable_path() -> Optional[Path]: - """ - Get path to KiCAD executable - - Returns: - Path to pcbnew/kicad executable, or None if not found - """ - system = platform.system() - - # Try to find executable in PATH first - for cmd in ["pcbnew", "kicad"]: - result = subprocess.run( - ["which", cmd] if system != "Windows" else ["where", cmd], - capture_output=True, - text=True, - encoding="mbcs" if system == "Windows" else None, - errors="ignore" if system == "Windows" else None, - timeout=5 if system == "Windows" else None, - ) - if result.returncode == 0: - exe_path = result.stdout.strip().split("\n")[0] - logger.info(f"Found KiCAD executable: {exe_path}") - return Path(exe_path) - - # Platform-specific default paths - if system == "Linux": - candidates = [ - Path("/usr/bin/pcbnew"), - Path("/usr/local/bin/pcbnew"), - Path("/usr/bin/kicad"), - ] - elif system == "Darwin": # macOS - candidates = [ - Path("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad"), - Path("/Applications/KiCad/pcbnew.app/Contents/MacOS/pcbnew"), - ] - elif system == "Windows": - candidates = [ - Path("C:/Program Files/KiCad/9.0/bin/pcbnew.exe"), - Path("C:/Program Files/KiCad/8.0/bin/pcbnew.exe"), - Path("C:/Program Files (x86)/KiCad/9.0/bin/pcbnew.exe"), - ] - else: - candidates = [] - - for path in candidates: - if path.exists(): - logger.info(f"Found KiCAD executable: {path}") - return path - - logger.warning("Could not find KiCAD executable") - return None - - @staticmethod - def launch(project_path: Optional[Path] = None, wait_for_start: bool = True) -> bool: - """ - Launch KiCAD PCB Editor - - Args: - project_path: Optional path to .kicad_pcb file to open - wait_for_start: Wait for process to start before returning - - Returns: - True if launch successful, False otherwise - """ - try: - # Check if already running - if KiCADProcessManager.is_running(): - logger.info("KiCAD is already running") - return True - - # Find executable - exe_path = KiCADProcessManager.get_executable_path() - if not exe_path: - logger.error("Cannot launch KiCAD: executable not found") - return False - - # Build command - cmd = [str(exe_path)] - if project_path: - cmd.append(str(project_path)) - - logger.info(f"Launching KiCAD: {' '.join(cmd)}") - - # Launch process in background - system = platform.system() - if system == "Windows": - # Windows: Use CREATE_NEW_PROCESS_GROUP to detach - subprocess.Popen( - cmd, - creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - else: - # Unix: Use nohup or start in background - subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=True, - ) - - # Wait for process to start - if wait_for_start: - logger.info("Waiting for KiCAD to start...") - for i in range(10): # Wait up to 5 seconds - time.sleep(0.5) - if KiCADProcessManager.is_running(): - logger.info("✓ KiCAD started successfully") - return True - - logger.warning("KiCAD process not detected after launch") - # Return True anyway, it might be starting - return True - - return True - - except Exception as e: - logger.error(f"Error launching KiCAD: {e}") - return False - - @staticmethod - def get_process_info() -> List[dict]: - """ - Get information about running KiCAD processes - - Returns: - List of process info dicts with pid, name, and command - """ - system = platform.system() - processes = [] - - try: - if system in ["Linux", "Darwin"]: - result = subprocess.run(["ps", "aux"], capture_output=True, text=True) - for line in result.stdout.split("\n"): - # Only match actual KiCAD binaries, not our MCP server processes - if ( - ("pcbnew" in line.lower() or "kicad" in line.lower()) - and "kicad_interface.py" not in line - and "grep" not in line - ): - # More specific check: must have /pcbnew or /kicad in the path - if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line: - parts = line.split() - if len(parts) >= 11: - processes.append( - { - "pid": parts[1], - "name": parts[10], - "command": " ".join(parts[10:]), - } - ) - - elif system == "Windows": - for proc in KiCADProcessManager._windows_list_processes(): - name = (proc.get("name") or "").lower() - if "pcbnew" in name or "kicad" in name: - processes.append(proc) - - except Exception as e: - logger.error(f"Error getting process info: {e}") - - return processes - - -def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict: - """ - Check if KiCAD is running and optionally launch it - - Args: - project_path: Optional path to .kicad_pcb file to open - auto_launch: If True, launch KiCAD if not running - - Returns: - Dict with status information - """ - manager = KiCADProcessManager() - - is_running = manager.is_running() - - if is_running: - processes = manager.get_process_info() - return { - "running": True, - "launched": False, - "processes": processes, - "message": "KiCAD is already running", - } - - if not auto_launch: - return { - "running": False, - "launched": False, - "processes": [], - "message": "KiCAD is not running (auto-launch disabled)", - } - - # Try to launch - logger.info("KiCAD not detected, attempting to launch...") - success = manager.launch(project_path) - - return { - "running": success, - "launched": success, - "processes": manager.get_process_info() if success else [], - "message": "KiCAD launched successfully" if success else "Failed to launch KiCAD", - "project": str(project_path) if project_path else None, - } +""" +KiCAD Process Management Utilities + +Detects if KiCAD is running and provides auto-launch functionality. +""" + +import ctypes +import logging +import os +import platform +import subprocess +import time +from ctypes import wintypes +from pathlib import Path +from typing import List, Optional + +logger = logging.getLogger(__name__) + + +class KiCADProcessManager: + """Manages KiCAD process detection and launching""" + + @staticmethod + def _windows_list_processes() -> List[dict]: + """List running processes on Windows using Toolhelp API.""" + processes: List[dict] = [] + try: + TH32CS_SNAPPROCESS = 0x00000002 + try: + ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined] + except AttributeError: + ulong_ptr = ( + ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong + ) + + class PROCESSENTRY32W(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ulong_ptr), + ("th32ModuleID", wintypes.DWORD), + ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), + ("pcPriClassBase", wintypes.LONG), + ("dwFlags", wintypes.DWORD), + ("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH), + ] + + CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot + Process32FirstW = ctypes.windll.kernel32.Process32FirstW + Process32NextW = ctypes.windll.kernel32.Process32NextW + CloseHandle = ctypes.windll.kernel32.CloseHandle + + snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if snapshot == wintypes.HANDLE(-1).value: + return processes + + entry = PROCESSENTRY32W() + entry.dwSize = ctypes.sizeof(PROCESSENTRY32W) + + if Process32FirstW(snapshot, ctypes.byref(entry)): + while True: + processes.append( + { + "pid": str(entry.th32ProcessID), + "name": entry.szExeFile, + "command": entry.szExeFile, + } + ) + if not Process32NextW(snapshot, ctypes.byref(entry)): + break + + CloseHandle(snapshot) + except Exception as e: + logger.error(f"Error listing Windows processes: {e}") + + return processes + + @staticmethod + def is_running() -> bool: + """ + Check if KiCAD is currently running + + Returns: + True if KiCAD process found, False otherwise + """ + system = platform.system() + + try: + if system == "Linux": + # Check for actual pcbnew/kicad binaries (not python scripts) + # Use exact process name matching to avoid matching our own kicad_interface.py + result = subprocess.run( + ["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True + ) + if result.returncode == 0: + return True + # Also check with -f for full path matching, but exclude our script + result = subprocess.run( + ["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True + ) + # Double-check it's not our own process + if result.returncode == 0: + pids = result.stdout.strip().split("\n") + for pid in pids: + try: + cmdline = subprocess.run( + ["ps", "-p", pid, "-o", "command="], capture_output=True, text=True + ) + if "kicad_interface.py" not in cmdline.stdout: + return True + except: + pass + return False + + elif system == "Darwin": # macOS + result = subprocess.run( + ["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True + ) + return result.returncode == 0 + + elif system == "Windows": + processes = KiCADProcessManager._windows_list_processes() + for proc in processes: + name = (proc.get("name") or "").lower() + if name in ("pcbnew.exe", "kicad.exe"): + return True + return False + + else: + logger.warning(f"Process detection not implemented for {system}") + return False + + except Exception as e: + logger.error(f"Error checking if KiCAD is running: {e}") + return False + + @staticmethod + def get_executable_path() -> Optional[Path]: + """ + Get path to KiCAD executable + + Returns: + Path to pcbnew/kicad executable, or None if not found + """ + system = platform.system() + + # Try to find executable in PATH first + for cmd in ["pcbnew", "kicad"]: + result = subprocess.run( + ["which", cmd] if system != "Windows" else ["where", cmd], + capture_output=True, + text=True, + encoding="mbcs" if system == "Windows" else None, + errors="ignore" if system == "Windows" else None, + timeout=5 if system == "Windows" else None, + ) + if result.returncode == 0: + exe_path = result.stdout.strip().split("\n")[0] + logger.info(f"Found KiCAD executable: {exe_path}") + return Path(exe_path) + + # Platform-specific default paths + if system == "Linux": + candidates = [ + Path("/usr/bin/pcbnew"), + Path("/usr/local/bin/pcbnew"), + Path("/usr/bin/kicad"), + ] + elif system == "Darwin": # macOS + candidates = [ + Path("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad"), + Path("/Applications/KiCad/pcbnew.app/Contents/MacOS/pcbnew"), + ] + elif system == "Windows": + candidates = [ + Path("C:/Program Files/KiCad/9.0/bin/pcbnew.exe"), + Path("C:/Program Files/KiCad/8.0/bin/pcbnew.exe"), + Path("C:/Program Files (x86)/KiCad/9.0/bin/pcbnew.exe"), + ] + else: + candidates = [] + + for path in candidates: + if path.exists(): + logger.info(f"Found KiCAD executable: {path}") + return path + + logger.warning("Could not find KiCAD executable") + return None + + @staticmethod + def launch(project_path: Optional[Path] = None, wait_for_start: bool = True) -> bool: + """ + Launch KiCAD PCB Editor + + Args: + project_path: Optional path to .kicad_pcb file to open + wait_for_start: Wait for process to start before returning + + Returns: + True if launch successful, False otherwise + """ + try: + # Check if already running + if KiCADProcessManager.is_running(): + logger.info("KiCAD is already running") + return True + + # Find executable + exe_path = KiCADProcessManager.get_executable_path() + if not exe_path: + logger.error("Cannot launch KiCAD: executable not found") + return False + + # Build command + cmd = [str(exe_path)] + if project_path: + cmd.append(str(project_path)) + + logger.info(f"Launching KiCAD: {' '.join(cmd)}") + + # Launch process in background + system = platform.system() + if system == "Windows": + # Windows: Use CREATE_NEW_PROCESS_GROUP to detach + subprocess.Popen( + cmd, + creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + else: + # Unix: Use nohup or start in background + subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + # Wait for process to start + if wait_for_start: + logger.info("Waiting for KiCAD to start...") + for i in range(10): # Wait up to 5 seconds + time.sleep(0.5) + if KiCADProcessManager.is_running(): + logger.info("✓ KiCAD started successfully") + return True + + logger.warning("KiCAD process not detected after launch") + # Return True anyway, it might be starting + return True + + return True + + except Exception as e: + logger.error(f"Error launching KiCAD: {e}") + return False + + @staticmethod + def get_process_info() -> List[dict]: + """ + Get information about running KiCAD processes + + Returns: + List of process info dicts with pid, name, and command + """ + system = platform.system() + processes = [] + + try: + if system in ["Linux", "Darwin"]: + result = subprocess.run(["ps", "aux"], capture_output=True, text=True) + for line in result.stdout.split("\n"): + # Only match actual KiCAD binaries, not our MCP server processes + if ( + ("pcbnew" in line.lower() or "kicad" in line.lower()) + and "kicad_interface.py" not in line + and "grep" not in line + ): + # More specific check: must have /pcbnew or /kicad in the path + if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line: + parts = line.split() + if len(parts) >= 11: + processes.append( + { + "pid": parts[1], + "name": parts[10], + "command": " ".join(parts[10:]), + } + ) + + elif system == "Windows": + for proc in KiCADProcessManager._windows_list_processes(): + name = (proc.get("name") or "").lower() + if "pcbnew" in name or "kicad" in name: + processes.append(proc) + + except Exception as e: + logger.error(f"Error getting process info: {e}") + + return processes + + +def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict: + """ + Check if KiCAD is running and optionally launch it + + Args: + project_path: Optional path to .kicad_pcb file to open + auto_launch: If True, launch KiCAD if not running + + Returns: + Dict with status information + """ + manager = KiCADProcessManager() + + is_running = manager.is_running() + + if is_running: + processes = manager.get_process_info() + return { + "running": True, + "launched": False, + "processes": processes, + "message": "KiCAD is already running", + } + + if not auto_launch: + return { + "running": False, + "launched": False, + "processes": [], + "message": "KiCAD is not running (auto-launch disabled)", + } + + # Try to launch + logger.info("KiCAD not detected, attempting to launch...") + success = manager.launch(project_path) + + return { + "running": success, + "launched": success, + "processes": manager.get_process_info() if success else [], + "message": "KiCAD launched successfully" if success else "Failed to launch KiCAD", + "project": str(project_path) if project_path else None, + } diff --git a/python/utils/platform_helper.py b/python/utils/platform_helper.py index 175db50..b014737 100644 --- a/python/utils/platform_helper.py +++ b/python/utils/platform_helper.py @@ -1,325 +1,325 @@ -""" -Platform detection and path utilities for cross-platform compatibility - -This module provides helpers for detecting the current platform and -getting appropriate paths for KiCAD, configuration, logs, etc. -""" - -import logging -import os -import platform -import sys -from pathlib import Path -from typing import List, Optional - -logger = logging.getLogger(__name__) - - -class PlatformHelper: - """Platform detection and path resolution utilities""" - - @staticmethod - def is_windows() -> bool: - """Check if running on Windows""" - return platform.system() == "Windows" - - @staticmethod - def is_linux() -> bool: - """Check if running on Linux""" - return platform.system() == "Linux" - - @staticmethod - def is_macos() -> bool: - """Check if running on macOS""" - return platform.system() == "Darwin" - - @staticmethod - def get_platform_name() -> str: - """Get human-readable platform name""" - system = platform.system() - if system == "Darwin": - return "macOS" - return system - - @staticmethod - def get_kicad_python_paths() -> List[Path]: - """ - Get potential KiCAD Python dist-packages paths for current platform - - Returns: - List of potential paths to check (in priority order) - """ - paths = [] - - if PlatformHelper.is_windows(): - # Windows: Check Program Files - program_files = [ - Path("C:/Program Files/KiCad"), - Path("C:/Program Files (x86)/KiCad"), - ] - for pf in program_files: - # Check multiple KiCAD versions - for version in ["9.0", "9.1", "10.0", "8.0"]: - path = pf / version / "lib" / "python3" / "dist-packages" - if path.exists(): - paths.append(path) - - elif PlatformHelper.is_linux(): - # Linux: Check common installation paths - candidates = [ - Path("/usr/lib/kicad/lib/python3/dist-packages"), - Path("/usr/share/kicad/scripting/plugins"), - Path("/usr/local/lib/kicad/lib/python3/dist-packages"), - Path.home() / ".local/lib/kicad/lib/python3/dist-packages", - ] - - # Also check based on Python version - py_version = f"{sys.version_info.major}.{sys.version_info.minor}" - candidates.extend( - [ - Path(f"/usr/lib/python{py_version}/dist-packages/kicad"), - Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"), - ] - ) - - # Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian) - # This is where pcbnew.py typically lives on modern systems - candidates.extend( - [ - Path(f"/usr/lib/python3/dist-packages"), - Path(f"/usr/lib/python{py_version}/dist-packages"), - Path(f"/usr/local/lib/python3/dist-packages"), - Path(f"/usr/local/lib/python{py_version}/dist-packages"), - ] - ) - - paths = [p for p in candidates if p.exists()] - - elif PlatformHelper.is_macos(): - # macOS: Check multiple KiCAD application bundle locations - kicad_app_paths = [ - Path("/Applications/KiCad/KiCad.app"), - Path("/Applications/KiCAD/KiCad.app"), # Alternative capitalization - Path.home() / "Applications" / "KiCad" / "KiCad.app", # User Applications - ] - - # Check Python framework paths in each KiCAD installation - for kicad_app in kicad_app_paths: - if kicad_app.exists(): - for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]: - path = ( - kicad_app - / "Contents" - / "Frameworks" - / "Python.framework" - / "Versions" - / version - / "lib" - / f"python{version}" - / "site-packages" - ) - if path.exists(): - paths.append(path) - - # Also check Homebrew Python site-packages (if pcbnew installed via pip) - homebrew_paths = [ - Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon - Path("/opt/homebrew/lib/python3.11/site-packages"), - Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac - Path("/usr/local/lib/python3.11/site-packages"), - ] - for hp in homebrew_paths: - pcbnew_path = hp / "pcbnew.py" - if pcbnew_path.exists(): - paths.append(hp) - - if not paths: - logger.warning(f"No KiCAD Python paths found for {PlatformHelper.get_platform_name()}") - else: - logger.info(f"Found {len(paths)} potential KiCAD Python paths") - - return paths - - @staticmethod - def get_kicad_python_path() -> Optional[Path]: - """ - Get the first valid KiCAD Python path - - Returns: - Path to KiCAD Python dist-packages, or None if not found - """ - paths = PlatformHelper.get_kicad_python_paths() - return paths[0] if paths else None - - @staticmethod - def get_kicad_library_search_paths() -> List[str]: - """ - Get platform-appropriate KiCAD symbol library search paths - - Returns: - List of glob patterns for finding .kicad_sym files - """ - patterns = [] - - if PlatformHelper.is_windows(): - patterns = [ - "C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", - "C:/Program Files (x86)/KiCad/*/share/kicad/symbols/*.kicad_sym", - ] - elif PlatformHelper.is_linux(): - patterns = [ - "/usr/share/kicad/symbols/*.kicad_sym", - "/usr/local/share/kicad/symbols/*.kicad_sym", - str(Path.home() / ".local/share/kicad/symbols/*.kicad_sym"), - ] - elif PlatformHelper.is_macos(): - patterns = [ - "/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", - "/Applications/KiCAD/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", - str( - Path.home() - / "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym" - ), - ] - - # Add user library paths for all platforms - patterns.append(str(Path.home() / "Documents" / "KiCad" / "*" / "symbols" / "*.kicad_sym")) - - return patterns - - @staticmethod - def get_config_dir() -> Path: - r""" - Get appropriate configuration directory for current platform - - Follows platform conventions: - - Windows: %USERPROFILE%\.kicad-mcp - - Linux: $XDG_CONFIG_HOME/kicad-mcp or ~/.config/kicad-mcp - - macOS: ~/Library/Application Support/kicad-mcp - - Returns: - Path to configuration directory - """ - if PlatformHelper.is_windows(): - return Path.home() / ".kicad-mcp" - elif PlatformHelper.is_linux(): - # Use XDG Base Directory specification - xdg_config = os.environ.get("XDG_CONFIG_HOME") - if xdg_config: - xdg_config_path = Path(xdg_config).expanduser() - if xdg_config_path.is_absolute(): - return xdg_config_path / "kicad-mcp" - logger.warning("Ignoring relative XDG_CONFIG_HOME: %s", xdg_config) - return Path.home() / ".config" / "kicad-mcp" - elif PlatformHelper.is_macos(): - return Path.home() / "Library" / "Application Support" / "kicad-mcp" - else: - # Fallback for unknown platforms - return Path.home() / ".kicad-mcp" - - @staticmethod - def get_log_dir() -> Path: - """ - Get appropriate log directory for current platform - - Returns: - Path to log directory - """ - config_dir = PlatformHelper.get_config_dir() - return config_dir / "logs" - - @staticmethod - def get_cache_dir() -> Path: - r""" - Get appropriate cache directory for current platform - - Follows platform conventions: - - Windows: %USERPROFILE%\.kicad-mcp\cache - - Linux: $XDG_CACHE_HOME/kicad-mcp or ~/.cache/kicad-mcp - - macOS: ~/Library/Caches/kicad-mcp - - Returns: - Path to cache directory - """ - if PlatformHelper.is_windows(): - return PlatformHelper.get_config_dir() / "cache" - elif PlatformHelper.is_linux(): - xdg_cache = os.environ.get("XDG_CACHE_HOME") - if xdg_cache: - xdg_cache_path = Path(xdg_cache).expanduser() - if xdg_cache_path.is_absolute(): - return xdg_cache_path / "kicad-mcp" - logger.warning("Ignoring relative XDG_CACHE_HOME: %s", xdg_cache) - return Path.home() / ".cache" / "kicad-mcp" - elif PlatformHelper.is_macos(): - return Path.home() / "Library" / "Caches" / "kicad-mcp" - else: - return PlatformHelper.get_config_dir() / "cache" - - @staticmethod - def ensure_directories() -> None: - """Create all necessary directories if they don't exist""" - dirs_to_create = [ - PlatformHelper.get_config_dir(), - PlatformHelper.get_log_dir(), - PlatformHelper.get_cache_dir(), - ] - - for directory in dirs_to_create: - directory.mkdir(parents=True, exist_ok=True) - logger.debug(f"Ensured directory exists: {directory}") - - @staticmethod - def get_python_executable() -> Path: - """Get path to current Python executable""" - return Path(sys.executable) - - @staticmethod - def add_kicad_to_python_path() -> bool: - """ - Add KiCAD Python paths to sys.path - - Returns: - True if at least one path was added, False otherwise - """ - paths_added = False - - for path in PlatformHelper.get_kicad_python_paths(): - if str(path) not in sys.path: - sys.path.insert(0, str(path)) - logger.info(f"Added to Python path: {path}") - paths_added = True - - return paths_added - - -# Convenience function for quick platform detection -def detect_platform() -> dict: - """ - Detect platform and return useful information - - Returns: - Dictionary with platform information - """ - return { - "system": platform.system(), - "platform": PlatformHelper.get_platform_name(), - "is_windows": PlatformHelper.is_windows(), - "is_linux": PlatformHelper.is_linux(), - "is_macos": PlatformHelper.is_macos(), - "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", - "python_executable": str(PlatformHelper.get_python_executable()), - "config_dir": str(PlatformHelper.get_config_dir()), - "log_dir": str(PlatformHelper.get_log_dir()), - "cache_dir": str(PlatformHelper.get_cache_dir()), - "kicad_python_paths": [str(p) for p in PlatformHelper.get_kicad_python_paths()], - } - - -if __name__ == "__main__": - # Quick test/diagnostic - import json - - info = detect_platform() - print("Platform Information:") - print(json.dumps(info, indent=2)) +""" +Platform detection and path utilities for cross-platform compatibility + +This module provides helpers for detecting the current platform and +getting appropriate paths for KiCAD, configuration, logs, etc. +""" + +import logging +import os +import platform +import sys +from pathlib import Path +from typing import List, Optional + +logger = logging.getLogger(__name__) + + +class PlatformHelper: + """Platform detection and path resolution utilities""" + + @staticmethod + def is_windows() -> bool: + """Check if running on Windows""" + return platform.system() == "Windows" + + @staticmethod + def is_linux() -> bool: + """Check if running on Linux""" + return platform.system() == "Linux" + + @staticmethod + def is_macos() -> bool: + """Check if running on macOS""" + return platform.system() == "Darwin" + + @staticmethod + def get_platform_name() -> str: + """Get human-readable platform name""" + system = platform.system() + if system == "Darwin": + return "macOS" + return system + + @staticmethod + def get_kicad_python_paths() -> List[Path]: + """ + Get potential KiCAD Python dist-packages paths for current platform + + Returns: + List of potential paths to check (in priority order) + """ + paths = [] + + if PlatformHelper.is_windows(): + # Windows: Check Program Files + program_files = [ + Path("C:/Program Files/KiCad"), + Path("C:/Program Files (x86)/KiCad"), + ] + for pf in program_files: + # Check multiple KiCAD versions + for version in ["9.0", "9.1", "10.0", "8.0"]: + path = pf / version / "lib" / "python3" / "dist-packages" + if path.exists(): + paths.append(path) + + elif PlatformHelper.is_linux(): + # Linux: Check common installation paths + candidates = [ + Path("/usr/lib/kicad/lib/python3/dist-packages"), + Path("/usr/share/kicad/scripting/plugins"), + Path("/usr/local/lib/kicad/lib/python3/dist-packages"), + Path.home() / ".local/lib/kicad/lib/python3/dist-packages", + ] + + # Also check based on Python version + py_version = f"{sys.version_info.major}.{sys.version_info.minor}" + candidates.extend( + [ + Path(f"/usr/lib/python{py_version}/dist-packages/kicad"), + Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"), + ] + ) + + # Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian) + # This is where pcbnew.py typically lives on modern systems + candidates.extend( + [ + Path(f"/usr/lib/python3/dist-packages"), + Path(f"/usr/lib/python{py_version}/dist-packages"), + Path(f"/usr/local/lib/python3/dist-packages"), + Path(f"/usr/local/lib/python{py_version}/dist-packages"), + ] + ) + + paths = [p for p in candidates if p.exists()] + + elif PlatformHelper.is_macos(): + # macOS: Check multiple KiCAD application bundle locations + kicad_app_paths = [ + Path("/Applications/KiCad/KiCad.app"), + Path("/Applications/KiCAD/KiCad.app"), # Alternative capitalization + Path.home() / "Applications" / "KiCad" / "KiCad.app", # User Applications + ] + + # Check Python framework paths in each KiCAD installation + for kicad_app in kicad_app_paths: + if kicad_app.exists(): + for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]: + path = ( + kicad_app + / "Contents" + / "Frameworks" + / "Python.framework" + / "Versions" + / version + / "lib" + / f"python{version}" + / "site-packages" + ) + if path.exists(): + paths.append(path) + + # Also check Homebrew Python site-packages (if pcbnew installed via pip) + homebrew_paths = [ + Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon + Path("/opt/homebrew/lib/python3.11/site-packages"), + Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac + Path("/usr/local/lib/python3.11/site-packages"), + ] + for hp in homebrew_paths: + pcbnew_path = hp / "pcbnew.py" + if pcbnew_path.exists(): + paths.append(hp) + + if not paths: + logger.warning(f"No KiCAD Python paths found for {PlatformHelper.get_platform_name()}") + else: + logger.info(f"Found {len(paths)} potential KiCAD Python paths") + + return paths + + @staticmethod + def get_kicad_python_path() -> Optional[Path]: + """ + Get the first valid KiCAD Python path + + Returns: + Path to KiCAD Python dist-packages, or None if not found + """ + paths = PlatformHelper.get_kicad_python_paths() + return paths[0] if paths else None + + @staticmethod + def get_kicad_library_search_paths() -> List[str]: + """ + Get platform-appropriate KiCAD symbol library search paths + + Returns: + List of glob patterns for finding .kicad_sym files + """ + patterns = [] + + if PlatformHelper.is_windows(): + patterns = [ + "C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", + "C:/Program Files (x86)/KiCad/*/share/kicad/symbols/*.kicad_sym", + ] + elif PlatformHelper.is_linux(): + patterns = [ + "/usr/share/kicad/symbols/*.kicad_sym", + "/usr/local/share/kicad/symbols/*.kicad_sym", + str(Path.home() / ".local/share/kicad/symbols/*.kicad_sym"), + ] + elif PlatformHelper.is_macos(): + patterns = [ + "/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", + "/Applications/KiCAD/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", + str( + Path.home() + / "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym" + ), + ] + + # Add user library paths for all platforms + patterns.append(str(Path.home() / "Documents" / "KiCad" / "*" / "symbols" / "*.kicad_sym")) + + return patterns + + @staticmethod + def get_config_dir() -> Path: + r""" + Get appropriate configuration directory for current platform + + Follows platform conventions: + - Windows: %USERPROFILE%\.kicad-mcp + - Linux: $XDG_CONFIG_HOME/kicad-mcp or ~/.config/kicad-mcp + - macOS: ~/Library/Application Support/kicad-mcp + + Returns: + Path to configuration directory + """ + if PlatformHelper.is_windows(): + return Path.home() / ".kicad-mcp" + elif PlatformHelper.is_linux(): + # Use XDG Base Directory specification + xdg_config = os.environ.get("XDG_CONFIG_HOME") + if xdg_config: + xdg_config_path = Path(xdg_config).expanduser() + if xdg_config_path.is_absolute(): + return xdg_config_path / "kicad-mcp" + logger.warning("Ignoring relative XDG_CONFIG_HOME: %s", xdg_config) + return Path.home() / ".config" / "kicad-mcp" + elif PlatformHelper.is_macos(): + return Path.home() / "Library" / "Application Support" / "kicad-mcp" + else: + # Fallback for unknown platforms + return Path.home() / ".kicad-mcp" + + @staticmethod + def get_log_dir() -> Path: + """ + Get appropriate log directory for current platform + + Returns: + Path to log directory + """ + config_dir = PlatformHelper.get_config_dir() + return config_dir / "logs" + + @staticmethod + def get_cache_dir() -> Path: + r""" + Get appropriate cache directory for current platform + + Follows platform conventions: + - Windows: %USERPROFILE%\.kicad-mcp\cache + - Linux: $XDG_CACHE_HOME/kicad-mcp or ~/.cache/kicad-mcp + - macOS: ~/Library/Caches/kicad-mcp + + Returns: + Path to cache directory + """ + if PlatformHelper.is_windows(): + return PlatformHelper.get_config_dir() / "cache" + elif PlatformHelper.is_linux(): + xdg_cache = os.environ.get("XDG_CACHE_HOME") + if xdg_cache: + xdg_cache_path = Path(xdg_cache).expanduser() + if xdg_cache_path.is_absolute(): + return xdg_cache_path / "kicad-mcp" + logger.warning("Ignoring relative XDG_CACHE_HOME: %s", xdg_cache) + return Path.home() / ".cache" / "kicad-mcp" + elif PlatformHelper.is_macos(): + return Path.home() / "Library" / "Caches" / "kicad-mcp" + else: + return PlatformHelper.get_config_dir() / "cache" + + @staticmethod + def ensure_directories() -> None: + """Create all necessary directories if they don't exist""" + dirs_to_create = [ + PlatformHelper.get_config_dir(), + PlatformHelper.get_log_dir(), + PlatformHelper.get_cache_dir(), + ] + + for directory in dirs_to_create: + directory.mkdir(parents=True, exist_ok=True) + logger.debug(f"Ensured directory exists: {directory}") + + @staticmethod + def get_python_executable() -> Path: + """Get path to current Python executable""" + return Path(sys.executable) + + @staticmethod + def add_kicad_to_python_path() -> bool: + """ + Add KiCAD Python paths to sys.path + + Returns: + True if at least one path was added, False otherwise + """ + paths_added = False + + for path in PlatformHelper.get_kicad_python_paths(): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + logger.info(f"Added to Python path: {path}") + paths_added = True + + return paths_added + + +# Convenience function for quick platform detection +def detect_platform() -> dict: + """ + Detect platform and return useful information + + Returns: + Dictionary with platform information + """ + return { + "system": platform.system(), + "platform": PlatformHelper.get_platform_name(), + "is_windows": PlatformHelper.is_windows(), + "is_linux": PlatformHelper.is_linux(), + "is_macos": PlatformHelper.is_macos(), + "python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + "python_executable": str(PlatformHelper.get_python_executable()), + "config_dir": str(PlatformHelper.get_config_dir()), + "log_dir": str(PlatformHelper.get_log_dir()), + "cache_dir": str(PlatformHelper.get_cache_dir()), + "kicad_python_paths": [str(p) for p in PlatformHelper.get_kicad_python_paths()], + } + + +if __name__ == "__main__": + # Quick test/diagnostic + import json + + info = detect_platform() + print("Platform Information:") + print(json.dumps(info, indent=2)) diff --git a/requirements-dev.txt b/requirements-dev.txt index e6acd95..1b2c9ba 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,29 +1,29 @@ -# KiCAD MCP Server - Development Dependencies -# Testing, linting, and development tools - -# Include production dependencies --r requirements.txt - -# Testing framework -pytest>=7.4.0 -pytest-cov>=4.1.0 -pytest-asyncio>=0.21.0 -pytest-mock>=3.11.0 - -# Code quality -black>=23.7.0 -mypy>=1.5.0 -pylint>=2.17.0 -flake8>=6.1.0 -isort>=5.12.0 - -# Type stubs -types-requests>=2.31.0 -types-Pillow>=10.0.0 - -# Pre-commit hooks -pre-commit>=3.3.0 - -# Development utilities -ipython>=8.14.0 -ipdb>=0.13.13 +# KiCAD MCP Server - Development Dependencies +# Testing, linting, and development tools + +# Include production dependencies +-r requirements.txt + +# Testing framework +pytest>=7.4.0 +pytest-cov>=4.1.0 +pytest-asyncio>=0.21.0 +pytest-mock>=3.11.0 + +# Code quality +black>=23.7.0 +mypy>=1.5.0 +pylint>=2.17.0 +flake8>=6.1.0 +isort>=5.12.0 + +# Type stubs +types-requests>=2.31.0 +types-Pillow>=10.0.0 + +# Pre-commit hooks +pre-commit>=3.3.0 + +# Development utilities +ipython>=8.14.0 +ipdb>=0.13.13 diff --git a/requirements.txt b/requirements.txt index bf6b93c..f926643 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,26 +1,26 @@ -# KiCAD MCP Server - Python Dependencies -# Production dependencies only - -# KiCAD Python API (IPC - for future migration) -# kicad-python>=0.5.0 # Uncomment when migrating to IPC API - -# Schematic manipulation -kicad-skip>=0.1.0 - -# Image processing for board rendering -Pillow>=9.0.0 - -# SVG rendering -cairosvg>=2.7.0 - -# Colored logging -colorlog>=6.7.0 - -# Data validation (for future features) -pydantic>=2.5.0 - -# HTTP requests (for JLCPCB/Digikey APIs - future) -requests>=2.32.5 - -# Environment variable management -python-dotenv>=1.0.0 +# KiCAD MCP Server - Python Dependencies +# Production dependencies only + +# KiCAD Python API (IPC - for future migration) +# kicad-python>=0.5.0 # Uncomment when migrating to IPC API + +# Schematic manipulation +kicad-skip>=0.1.0 + +# Image processing for board rendering +Pillow>=9.0.0 + +# SVG rendering +cairosvg>=2.7.0 + +# Colored logging +colorlog>=6.7.0 + +# Data validation (for future features) +pydantic>=2.5.0 + +# HTTP requests (for JLCPCB/Digikey APIs - future) +requests>=2.32.5 + +# Environment variable management +python-dotenv>=1.0.0 diff --git a/scripts/install-linux.sh b/scripts/install-linux.sh index f43efa0..742795a 100755 --- a/scripts/install-linux.sh +++ b/scripts/install-linux.sh @@ -1,165 +1,165 @@ -#!/bin/bash -# KiCAD MCP Server - Linux Installation Script -# Supports Ubuntu/Debian-based distributions - -set -e # Exit on error - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Print colored messages -print_info() { echo -e "${BLUE}ℹ${NC} $1"; } -print_success() { echo -e "${GREEN}✓${NC} $1"; } -print_warning() { echo -e "${YELLOW}⚠${NC} $1"; } -print_error() { echo -e "${RED}✗${NC} $1"; } - -# Header -echo "" -echo "╔═══════════════════════════════════════════════════════════════╗" -echo "║ KiCAD MCP Server - Linux Installation ║" -echo "║ ║" -echo "║ This script will install: ║" -echo "║ - KiCAD 9.0 ║" -echo "║ - Node.js 20.x ║" -echo "║ - Python dependencies ║" -echo "║ - Build the TypeScript server ║" -echo "╚═══════════════════════════════════════════════════════════════╝" -echo "" - -# Check if running on Linux -if [[ "$OSTYPE" != "linux-gnu"* ]]; then - print_error "This script is for Linux only. Detected: $OSTYPE" - exit 1 -fi - -# Check for Ubuntu/Debian -if ! command -v apt-get &> /dev/null; then - print_warning "This script is optimized for Ubuntu/Debian" - print_warning "For other distributions, please install manually" - read -p "Continue anyway? (y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit 1 - fi -fi - -# Function to check if command exists -command_exists() { - command -v "$1" &> /dev/null -} - -# Step 1: Install KiCAD 9.0 -print_info "Step 1/5: Installing KiCAD 9.0..." -if command_exists kicad; then - KICAD_VERSION=$(kicad-cli version 2>/dev/null | head -n 1 || echo "unknown") - print_success "KiCAD is already installed: $KICAD_VERSION" -else - print_info "Adding KiCAD PPA and installing..." - sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases - sudo apt-get update - sudo apt-get install -y kicad kicad-libraries - print_success "KiCAD 9.0 installed" -fi - -# Verify KiCAD Python module -print_info "Verifying KiCAD Python module..." -if python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" 2>/dev/null; then - PCBNEW_VERSION=$(python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())") - print_success "KiCAD Python module (pcbnew) found: $PCBNEW_VERSION" -else - print_warning "KiCAD Python module (pcbnew) not found in default Python path" - print_warning "You may need to set PYTHONPATH manually" -fi - -# Step 2: Install Node.js -print_info "Step 2/5: Installing Node.js 20.x..." -if command_exists node; then - NODE_VERSION=$(node --version) - MAJOR_VERSION=$(echo $NODE_VERSION | cut -d'.' -f1 | sed 's/v//') - if [ "$MAJOR_VERSION" -ge 18 ]; then - print_success "Node.js is already installed: $NODE_VERSION" - else - print_warning "Node.js version is too old: $NODE_VERSION (need 18+)" - print_info "Installing Node.js 20.x..." - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - - sudo apt-get install -y nodejs - print_success "Node.js updated" - fi -else - print_info "Installing Node.js 20.x..." - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - - sudo apt-get install -y nodejs - print_success "Node.js installed: $(node --version)" -fi - -# Step 3: Install Python dependencies -print_info "Step 3/5: Installing Python dependencies..." -if [ -f "requirements.txt" ]; then - pip3 install --user -r requirements.txt - print_success "Python dependencies installed" -else - print_warning "requirements.txt not found - skipping Python dependencies" -fi - -# Step 4: Install Node.js dependencies -print_info "Step 4/5: Installing Node.js dependencies..." -if [ -f "package.json" ]; then - npm install - print_success "Node.js dependencies installed" -else - print_error "package.json not found! Are you in the correct directory?" - exit 1 -fi - -# Step 5: Build TypeScript -print_info "Step 5/5: Building TypeScript..." -npm run build -print_success "TypeScript build complete" - -# Final checks -echo "" -print_info "Running final checks..." - -# Check if dist directory was created -if [ -d "dist" ]; then - print_success "dist/ directory created" -else - print_error "dist/ directory not found - build may have failed" - exit 1 -fi - -# Test platform helper -print_info "Testing platform detection..." -if python3 python/utils/platform_helper.py > /dev/null 2>&1; then - print_success "Platform helper working" -else - print_warning "Platform helper test failed" -fi - -# Installation complete -echo "" -echo "╔═══════════════════════════════════════════════════════════════╗" -echo "║ 🎉 Installation Complete! 🎉 ║" -echo "╚═══════════════════════════════════════════════════════════════╝" -echo "" -print_success "KiCAD MCP Server is ready to use!" -echo "" -print_info "Next steps:" -echo " 1. Configure Cline in VSCode with the path to dist/index.js" -echo " 2. Set PYTHONPATH in Cline config (see README.md)" -echo " 3. Restart VSCode" -echo " 4. Test with: 'Create a new KiCAD project named TestProject'" -echo "" -print_info "For detailed configuration, see:" -echo " - README.md (Linux section)" -echo " - config/linux-config.example.json" -echo "" -print_info "To run tests:" -echo " pytest tests/" -echo "" -print_info "Need help? Check docs/LINUX_COMPATIBILITY_AUDIT.md" -echo "" +#!/bin/bash +# KiCAD MCP Server - Linux Installation Script +# Supports Ubuntu/Debian-based distributions + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Print colored messages +print_info() { echo -e "${BLUE}ℹ${NC} $1"; } +print_success() { echo -e "${GREEN}✓${NC} $1"; } +print_warning() { echo -e "${YELLOW}⚠${NC} $1"; } +print_error() { echo -e "${RED}✗${NC} $1"; } + +# Header +echo "" +echo "╔═══════════════════════════════════════════════════════════════╗" +echo "║ KiCAD MCP Server - Linux Installation ║" +echo "║ ║" +echo "║ This script will install: ║" +echo "║ - KiCAD 9.0 ║" +echo "║ - Node.js 20.x ║" +echo "║ - Python dependencies ║" +echo "║ - Build the TypeScript server ║" +echo "╚═══════════════════════════════════════════════════════════════╝" +echo "" + +# Check if running on Linux +if [[ "$OSTYPE" != "linux-gnu"* ]]; then + print_error "This script is for Linux only. Detected: $OSTYPE" + exit 1 +fi + +# Check for Ubuntu/Debian +if ! command -v apt-get &> /dev/null; then + print_warning "This script is optimized for Ubuntu/Debian" + print_warning "For other distributions, please install manually" + read -p "Continue anyway? (y/N) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +fi + +# Function to check if command exists +command_exists() { + command -v "$1" &> /dev/null +} + +# Step 1: Install KiCAD 9.0 +print_info "Step 1/5: Installing KiCAD 9.0..." +if command_exists kicad; then + KICAD_VERSION=$(kicad-cli version 2>/dev/null | head -n 1 || echo "unknown") + print_success "KiCAD is already installed: $KICAD_VERSION" +else + print_info "Adding KiCAD PPA and installing..." + sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases + sudo apt-get update + sudo apt-get install -y kicad kicad-libraries + print_success "KiCAD 9.0 installed" +fi + +# Verify KiCAD Python module +print_info "Verifying KiCAD Python module..." +if python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" 2>/dev/null; then + PCBNEW_VERSION=$(python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())") + print_success "KiCAD Python module (pcbnew) found: $PCBNEW_VERSION" +else + print_warning "KiCAD Python module (pcbnew) not found in default Python path" + print_warning "You may need to set PYTHONPATH manually" +fi + +# Step 2: Install Node.js +print_info "Step 2/5: Installing Node.js 20.x..." +if command_exists node; then + NODE_VERSION=$(node --version) + MAJOR_VERSION=$(echo $NODE_VERSION | cut -d'.' -f1 | sed 's/v//') + if [ "$MAJOR_VERSION" -ge 18 ]; then + print_success "Node.js is already installed: $NODE_VERSION" + else + print_warning "Node.js version is too old: $NODE_VERSION (need 18+)" + print_info "Installing Node.js 20.x..." + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs + print_success "Node.js updated" + fi +else + print_info "Installing Node.js 20.x..." + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs + print_success "Node.js installed: $(node --version)" +fi + +# Step 3: Install Python dependencies +print_info "Step 3/5: Installing Python dependencies..." +if [ -f "requirements.txt" ]; then + pip3 install --user -r requirements.txt + print_success "Python dependencies installed" +else + print_warning "requirements.txt not found - skipping Python dependencies" +fi + +# Step 4: Install Node.js dependencies +print_info "Step 4/5: Installing Node.js dependencies..." +if [ -f "package.json" ]; then + npm install + print_success "Node.js dependencies installed" +else + print_error "package.json not found! Are you in the correct directory?" + exit 1 +fi + +# Step 5: Build TypeScript +print_info "Step 5/5: Building TypeScript..." +npm run build +print_success "TypeScript build complete" + +# Final checks +echo "" +print_info "Running final checks..." + +# Check if dist directory was created +if [ -d "dist" ]; then + print_success "dist/ directory created" +else + print_error "dist/ directory not found - build may have failed" + exit 1 +fi + +# Test platform helper +print_info "Testing platform detection..." +if python3 python/utils/platform_helper.py > /dev/null 2>&1; then + print_success "Platform helper working" +else + print_warning "Platform helper test failed" +fi + +# Installation complete +echo "" +echo "╔═══════════════════════════════════════════════════════════════╗" +echo "║ 🎉 Installation Complete! 🎉 ║" +echo "╚═══════════════════════════════════════════════════════════════╝" +echo "" +print_success "KiCAD MCP Server is ready to use!" +echo "" +print_info "Next steps:" +echo " 1. Configure Cline in VSCode with the path to dist/index.js" +echo " 2. Set PYTHONPATH in Cline config (see README.md)" +echo " 3. Restart VSCode" +echo " 4. Test with: 'Create a new KiCAD project named TestProject'" +echo "" +print_info "For detailed configuration, see:" +echo " - README.md (Linux section)" +echo " - config/linux-config.example.json" +echo "" +print_info "To run tests:" +echo " pytest tests/" +echo "" +print_info "Need help? Check docs/LINUX_COMPATIBILITY_AUDIT.md" +echo "" diff --git a/setup-windows.ps1 b/setup-windows.ps1 index 523f103..1d04ea4 100644 --- a/setup-windows.ps1 +++ b/setup-windows.ps1 @@ -1,410 +1,410 @@ -<# -.SYNOPSIS - KiCAD MCP Server - Windows Setup and Configuration Script - -.DESCRIPTION - This script automates the setup of KiCAD MCP Server on Windows by: - - Detecting KiCAD installation and version - - Verifying Python and Node.js installations - - Testing KiCAD Python module (pcbnew) - - Installing required Python dependencies - - Building the TypeScript project - - Generating Claude Desktop configuration - - Running diagnostic tests - -.PARAMETER SkipBuild - Skip the npm build step (useful if already built) - -.PARAMETER ClientType - Type of MCP client to configure: 'claude-desktop', 'cline', or 'manual' - Default: 'claude-desktop' - -.EXAMPLE - .\setup-windows.ps1 - Run the full setup with default options - -.EXAMPLE - .\setup-windows.ps1 -ClientType cline - Setup for Cline VSCode extension - -.EXAMPLE - .\setup-windows.ps1 -SkipBuild - Run setup without rebuilding the project -#> - -param( - [switch]$SkipBuild, - [ValidateSet('claude-desktop', 'cline', 'manual')] - [string]$ClientType = 'claude-desktop' -) - -# Color output helpers -function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green } -function Write-Error-Custom { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red } -function Write-Warning-Custom { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow } -function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan } -function Write-Step { param([string]$Message) Write-Host "`n=== $Message ===" -ForegroundColor Magenta } - -Write-Host @" -╔════════════════════════════════════════════════════════════╗ -║ KiCAD MCP Server - Windows Setup Script ║ -║ ║ -║ This script will configure KiCAD MCP for Windows ║ -╚════════════════════════════════════════════════════════════╝ -"@ -ForegroundColor Cyan - -# Store results for final report -$script:Results = @{ - KiCADFound = $false - KiCADVersion = "" - KiCADPythonPath = "" - PythonFound = $false - PythonVersion = "" - NodeFound = $false - NodeVersion = "" - PcbnewImport = $false - DependenciesInstalled = $false - ProjectBuilt = $false - ConfigGenerated = $false - Errors = @() -} - -# Get script directory (project root) -$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path - -Write-Step "Step 1: Detecting KiCAD Installation" - -# Function to find KiCAD installation -function Find-KiCAD { - $possiblePaths = @( - "C:\Program Files\KiCad", - "C:\Program Files (x86)\KiCad" - "$env:USERPROFILE\AppData\Local\Programs\KiCad" - ) - - $versions = @("9.0", "9.1", "10.0", "8.0") - - foreach ($basePath in $possiblePaths) { - foreach ($version in $versions) { - $kicadPath = Join-Path $basePath $version - $pythonExe = Join-Path $kicadPath "bin\python.exe" - $pythonLib = Join-Path $kicadPath "lib\python3\dist-packages" - - if (Test-Path $pythonExe) { - Write-Success "Found KiCAD $version at: $kicadPath" - return @{ - Path = $kicadPath - Version = $version - PythonExe = $pythonExe - PythonLib = $pythonLib - } - } - } - } - - return $null -} - -$kicad = Find-KiCAD - -if ($kicad) { - $script:Results.KiCADFound = $true - $script:Results.KiCADVersion = $kicad.Version - $script:Results.KiCADPythonPath = $kicad.PythonLib - Write-Info "KiCAD Version: $($kicad.Version)" - Write-Info "Python Path: $($kicad.PythonLib)" -} else { - Write-Error-Custom "KiCAD not found in standard locations" - Write-Warning-Custom "Checked: C:\Program Files, C:\Program Files (x86), and $env:USERPROFILE\AppData\Local\Programs" - Write-Warning-Custom "Please install KiCAD 9.0+ from https://www.kicad.org/download/windows/" - $script:Results.Errors += "KiCAD not found" -} - -Write-Step "Step 2: Checking Node.js Installation" - -try { - $nodeVersion = node --version 2>$null - if ($LASTEXITCODE -eq 0) { - Write-Success "Node.js found: $nodeVersion" - $script:Results.NodeFound = $true - $script:Results.NodeVersion = $nodeVersion - - # Check if version is 18+ - $versionNumber = [int]($nodeVersion -replace 'v(\d+)\..*', '$1') - if ($versionNumber -lt 18) { - Write-Warning-Custom "Node.js version 18+ is recommended (you have $nodeVersion)" - } - } -} catch { - Write-Error-Custom "Node.js not found" - Write-Warning-Custom "Please install Node.js 18+ from https://nodejs.org/" - $script:Results.Errors += "Node.js not found" -} - -Write-Step "Step 3: Testing KiCAD Python Module" - -if ($kicad) { - Write-Info "Testing pcbnew module import..." - - $testScript = "import sys; import pcbnew; print(f'SUCCESS:{pcbnew.GetBuildVersion()}')" - $result = & $kicad.PythonExe -c $testScript 2>&1 - - if ($result -match "SUCCESS:(.+)") { - $pcbnewVersion = $matches[1] - Write-Success "pcbnew module imported successfully: $pcbnewVersion" - $script:Results.PcbnewImport = $true - } else { - Write-Error-Custom "Failed to import pcbnew module" - Write-Warning-Custom "Error: $result" - Write-Info "This usually means KiCAD was not installed with Python support" - $script:Results.Errors += "pcbnew import failed: $result" - } -} else { - Write-Warning-Custom "Skipping pcbnew test (KiCAD not found)" -} - -Write-Step "Step 4: Checking Python Installation" - -try { - $pythonVersion = python --version 2>&1 - if ($pythonVersion -match "Python (\d+\.\d+\.\d+)") { - Write-Success "System Python found: $pythonVersion" - $script:Results.PythonFound = $true - $script:Results.PythonVersion = $pythonVersion - } -} catch { - Write-Warning-Custom "System Python not found (using KiCAD's Python)" -} - -Write-Step "Step 5: Installing Node.js Dependencies" - -if ($script:Results.NodeFound) { - Write-Info "Running npm install..." - Push-Location $ProjectRoot - try { - npm install 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Success "Node.js dependencies installed" - } else { - Write-Error-Custom "npm install failed" - $script:Results.Errors += "npm install failed" - } - } finally { - Pop-Location - } -} else { - Write-Warning-Custom "Skipping npm install (Node.js not found)" -} - -Write-Step "Step 6: Installing Python Dependencies" - -if ($kicad) { - Write-Info "Installing Python packages..." - Push-Location $ProjectRoot - try { - $requirementsFile = Join-Path $ProjectRoot "requirements.txt" - if (Test-Path $requirementsFile) { - & $kicad.PythonExe -m pip install -r $requirementsFile 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - Write-Success "Python dependencies installed" - $script:Results.DependenciesInstalled = $true - } else { - Write-Warning-Custom "Some Python packages may have failed to install" - } - } else { - Write-Warning-Custom "requirements.txt not found" - } - } finally { - Pop-Location - } -} else { - Write-Warning-Custom "Skipping Python dependencies (KiCAD Python not found)" -} - -Write-Step "Step 7: Building TypeScript Project" - -if (-not $SkipBuild -and $script:Results.NodeFound) { - Write-Info "Running npm run build..." - Push-Location $ProjectRoot - try { - npm run build 2>&1 | Out-Null - if ($LASTEXITCODE -eq 0) { - $distPath = Join-Path $ProjectRoot "dist\index.js" - if (Test-Path $distPath) { - Write-Success "Project built successfully" - $script:Results.ProjectBuilt = $true - } else { - Write-Error-Custom "Build completed but dist/index.js not found" - $script:Results.Errors += "Build output missing" - } - } else { - Write-Error-Custom "Build failed" - $script:Results.Errors += "TypeScript build failed" - } - } finally { - Pop-Location - } -} else { - if ($SkipBuild) { - Write-Info "Skipping build (--SkipBuild specified)" - } else { - Write-Warning-Custom "Skipping build (Node.js not found)" - } -} - -Write-Step "Step 8: Generating Configuration" - -if ($kicad -and $script:Results.ProjectBuilt) { - $distPath = Join-Path $ProjectRoot "dist\index.js" - $distPathEscaped = $distPath -replace '\\', '\\' - $pythonLibEscaped = $kicad.PythonLib -replace '\\', '\\' - - $config = @" -{ - "mcpServers": { - "kicad": { - "command": "node", - "args": ["$distPathEscaped"], - "env": { - "PYTHONPATH": "$pythonLibEscaped", - "NODE_ENV": "production", - "LOG_LEVEL": "info" - } - } - } -} -"@ - - $configPath = Join-Path $ProjectRoot "windows-mcp-config.json" - $config | Out-File -FilePath $configPath -Encoding UTF8 - Write-Success "Configuration generated: $configPath" - $script:Results.ConfigGenerated = $true - - Write-Info "`nConfiguration Preview:" - Write-Host $config -ForegroundColor Gray - - # Provide instructions based on client type - Write-Info "`nTo use this configuration:" - - if ($ClientType -eq 'claude-desktop') { - $claudeConfigPath = "$env:APPDATA\Claude\claude_desktop_config.json" - Write-Host "`n1. Open Claude Desktop configuration:" -ForegroundColor Yellow - Write-Host " $claudeConfigPath" -ForegroundColor White - Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow - Write-Host " $configPath" -ForegroundColor White - Write-Host "`n3. Restart Claude Desktop" -ForegroundColor Yellow - } elseif ($ClientType -eq 'cline') { - $clineConfigPath = "$env:APPDATA\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json" - Write-Host "`n1. Open Cline configuration:" -ForegroundColor Yellow - Write-Host " $clineConfigPath" -ForegroundColor White - Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow - Write-Host " $configPath" -ForegroundColor White - Write-Host "`n3. Restart VSCode" -ForegroundColor Yellow - } else { - Write-Host "`n1. Configuration saved to:" -ForegroundColor Yellow - Write-Host " $configPath" -ForegroundColor White - Write-Host "`n2. Copy to your MCP client configuration" -ForegroundColor Yellow - } - -} else { - Write-Warning-Custom "Skipping configuration generation (prerequisites not met)" -} - -Write-Step "Step 9: Running Diagnostic Test" - -if ($kicad -and $script:Results.ProjectBuilt) { - Write-Info "Testing server startup..." - - $env:PYTHONPATH = $kicad.PythonLib - $distPath = Join-Path $ProjectRoot "dist\index.js" - - # Start the server process - $process = Start-Process -FilePath "node" ` - -ArgumentList $distPath ` - -NoNewWindow ` - -PassThru ` - -RedirectStandardError (Join-Path $env:TEMP "kicad-mcp-test-error.txt") ` - -RedirectStandardOutput (Join-Path $env:TEMP "kicad-mcp-test-output.txt") - - # Wait a moment for startup - Start-Sleep -Seconds 2 - - if (-not $process.HasExited) { - Write-Success "Server started successfully (PID: $($process.Id))" - Write-Info "Stopping test server..." - Stop-Process -Id $process.Id -Force - } else { - Write-Error-Custom "Server exited immediately (exit code: $($process.ExitCode))" - - $errorLog = Join-Path $env:TEMP "kicad-mcp-test-error.txt" - if (Test-Path $errorLog) { - $errorContent = Get-Content $errorLog -Raw - if ($errorContent) { - Write-Warning-Custom "Error output:" - Write-Host $errorContent -ForegroundColor Red - } - } - - $script:Results.Errors += "Server startup test failed" - } -} else { - Write-Warning-Custom "Skipping diagnostic test (prerequisites not met)" -} - -# Final Report -Write-Step "Setup Summary" - -Write-Host "`nComponent Status:" -ForegroundColor Cyan -Write-Host " KiCAD Installation: $(if ($script:Results.KiCADFound) { '[OK] Found' } else { '[ERROR] Not Found' })" -ForegroundColor $(if ($script:Results.KiCADFound) { 'Green' } else { 'Red' }) -if ($script:Results.KiCADVersion) { - Write-Host " Version: $($script:Results.KiCADVersion)" -ForegroundColor Gray -} -Write-Host " pcbnew Module: $(if ($script:Results.PcbnewImport) { '[OK] Working' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.PcbnewImport) { 'Green' } else { 'Red' }) -Write-Host " Node.js: $(if ($script:Results.NodeFound) { '[OK] Found' } else { '[ERROR] Not Found' })" -ForegroundColor $(if ($script:Results.NodeFound) { 'Green' } else { 'Red' }) -if ($script:Results.NodeVersion) { - Write-Host " Version: $($script:Results.NodeVersion)" -ForegroundColor Gray -} -Write-Host " Python Dependencies: $(if ($script:Results.DependenciesInstalled) { '[OK] Installed' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.DependenciesInstalled) { 'Green' } else { 'Red' }) -Write-Host " Project Build: $(if ($script:Results.ProjectBuilt) { '[OK] Success' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.ProjectBuilt) { 'Green' } else { 'Red' }) -Write-Host " Configuration: $(if ($script:Results.ConfigGenerated) { '[OK] Generated' } else { '[ERROR] Not Generated' })" -ForegroundColor $(if ($script:Results.ConfigGenerated) { 'Green' } else { 'Red' }) - -if ($script:Results.Errors.Count -gt 0) { - Write-Host "`nErrors Encountered:" -ForegroundColor Red - foreach ($error in $script:Results.Errors) { - Write-Host " • $error" -ForegroundColor Red - } -} - -# Check for log file -$logPath = "$env:USERPROFILE\.kicad-mcp\logs\kicad_interface.log" -if (Test-Path $logPath) { - Write-Host "`nLog file location:" -ForegroundColor Cyan - Write-Host " $logPath" -ForegroundColor Gray -} - -# Success criteria -$isSuccess = $script:Results.KiCADFound -and - $script:Results.PcbnewImport -and - $script:Results.NodeFound -and - $script:Results.ProjectBuilt - -if ($isSuccess) { - Write-Host "`n============================================================" -ForegroundColor Green - Write-Host " [OK] Setup completed successfully!" -ForegroundColor Green - Write-Host "" -ForegroundColor Green - Write-Host " Next steps:" -ForegroundColor Green - Write-Host " 1. Copy the generated config to your MCP client" -ForegroundColor Green - Write-Host " 2. Restart your MCP client (Claude Desktop/Cline)" -ForegroundColor Green - Write-Host " 3. Try: 'Create a new KiCAD project'" -ForegroundColor Green - Write-Host "============================================================" -ForegroundColor Green -} else { - Write-Host "`n============================================================" -ForegroundColor Red - Write-Host " [ERROR] Setup incomplete - issues detected" -ForegroundColor Red - Write-Host "" -ForegroundColor Red - Write-Host " Please resolve the errors above and run again" -ForegroundColor Red - Write-Host "" -ForegroundColor Red - Write-Host " For help:" -ForegroundColor Red - Write-Host " https://github.com/mixelpixx/KiCAD-MCP-Server/issues" -ForegroundColor Red - Write-Host "============================================================" -ForegroundColor Red - exit 1 -} +<# +.SYNOPSIS + KiCAD MCP Server - Windows Setup and Configuration Script + +.DESCRIPTION + This script automates the setup of KiCAD MCP Server on Windows by: + - Detecting KiCAD installation and version + - Verifying Python and Node.js installations + - Testing KiCAD Python module (pcbnew) + - Installing required Python dependencies + - Building the TypeScript project + - Generating Claude Desktop configuration + - Running diagnostic tests + +.PARAMETER SkipBuild + Skip the npm build step (useful if already built) + +.PARAMETER ClientType + Type of MCP client to configure: 'claude-desktop', 'cline', or 'manual' + Default: 'claude-desktop' + +.EXAMPLE + .\setup-windows.ps1 + Run the full setup with default options + +.EXAMPLE + .\setup-windows.ps1 -ClientType cline + Setup for Cline VSCode extension + +.EXAMPLE + .\setup-windows.ps1 -SkipBuild + Run setup without rebuilding the project +#> + +param( + [switch]$SkipBuild, + [ValidateSet('claude-desktop', 'cline', 'manual')] + [string]$ClientType = 'claude-desktop' +) + +# Color output helpers +function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green } +function Write-Error-Custom { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red } +function Write-Warning-Custom { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow } +function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan } +function Write-Step { param([string]$Message) Write-Host "`n=== $Message ===" -ForegroundColor Magenta } + +Write-Host @" +╔════════════════════════════════════════════════════════════╗ +║ KiCAD MCP Server - Windows Setup Script ║ +║ ║ +║ This script will configure KiCAD MCP for Windows ║ +╚════════════════════════════════════════════════════════════╝ +"@ -ForegroundColor Cyan + +# Store results for final report +$script:Results = @{ + KiCADFound = $false + KiCADVersion = "" + KiCADPythonPath = "" + PythonFound = $false + PythonVersion = "" + NodeFound = $false + NodeVersion = "" + PcbnewImport = $false + DependenciesInstalled = $false + ProjectBuilt = $false + ConfigGenerated = $false + Errors = @() +} + +# Get script directory (project root) +$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path + +Write-Step "Step 1: Detecting KiCAD Installation" + +# Function to find KiCAD installation +function Find-KiCAD { + $possiblePaths = @( + "C:\Program Files\KiCad", + "C:\Program Files (x86)\KiCad" + "$env:USERPROFILE\AppData\Local\Programs\KiCad" + ) + + $versions = @("9.0", "9.1", "10.0", "8.0") + + foreach ($basePath in $possiblePaths) { + foreach ($version in $versions) { + $kicadPath = Join-Path $basePath $version + $pythonExe = Join-Path $kicadPath "bin\python.exe" + $pythonLib = Join-Path $kicadPath "lib\python3\dist-packages" + + if (Test-Path $pythonExe) { + Write-Success "Found KiCAD $version at: $kicadPath" + return @{ + Path = $kicadPath + Version = $version + PythonExe = $pythonExe + PythonLib = $pythonLib + } + } + } + } + + return $null +} + +$kicad = Find-KiCAD + +if ($kicad) { + $script:Results.KiCADFound = $true + $script:Results.KiCADVersion = $kicad.Version + $script:Results.KiCADPythonPath = $kicad.PythonLib + Write-Info "KiCAD Version: $($kicad.Version)" + Write-Info "Python Path: $($kicad.PythonLib)" +} else { + Write-Error-Custom "KiCAD not found in standard locations" + Write-Warning-Custom "Checked: C:\Program Files, C:\Program Files (x86), and $env:USERPROFILE\AppData\Local\Programs" + Write-Warning-Custom "Please install KiCAD 9.0+ from https://www.kicad.org/download/windows/" + $script:Results.Errors += "KiCAD not found" +} + +Write-Step "Step 2: Checking Node.js Installation" + +try { + $nodeVersion = node --version 2>$null + if ($LASTEXITCODE -eq 0) { + Write-Success "Node.js found: $nodeVersion" + $script:Results.NodeFound = $true + $script:Results.NodeVersion = $nodeVersion + + # Check if version is 18+ + $versionNumber = [int]($nodeVersion -replace 'v(\d+)\..*', '$1') + if ($versionNumber -lt 18) { + Write-Warning-Custom "Node.js version 18+ is recommended (you have $nodeVersion)" + } + } +} catch { + Write-Error-Custom "Node.js not found" + Write-Warning-Custom "Please install Node.js 18+ from https://nodejs.org/" + $script:Results.Errors += "Node.js not found" +} + +Write-Step "Step 3: Testing KiCAD Python Module" + +if ($kicad) { + Write-Info "Testing pcbnew module import..." + + $testScript = "import sys; import pcbnew; print(f'SUCCESS:{pcbnew.GetBuildVersion()}')" + $result = & $kicad.PythonExe -c $testScript 2>&1 + + if ($result -match "SUCCESS:(.+)") { + $pcbnewVersion = $matches[1] + Write-Success "pcbnew module imported successfully: $pcbnewVersion" + $script:Results.PcbnewImport = $true + } else { + Write-Error-Custom "Failed to import pcbnew module" + Write-Warning-Custom "Error: $result" + Write-Info "This usually means KiCAD was not installed with Python support" + $script:Results.Errors += "pcbnew import failed: $result" + } +} else { + Write-Warning-Custom "Skipping pcbnew test (KiCAD not found)" +} + +Write-Step "Step 4: Checking Python Installation" + +try { + $pythonVersion = python --version 2>&1 + if ($pythonVersion -match "Python (\d+\.\d+\.\d+)") { + Write-Success "System Python found: $pythonVersion" + $script:Results.PythonFound = $true + $script:Results.PythonVersion = $pythonVersion + } +} catch { + Write-Warning-Custom "System Python not found (using KiCAD's Python)" +} + +Write-Step "Step 5: Installing Node.js Dependencies" + +if ($script:Results.NodeFound) { + Write-Info "Running npm install..." + Push-Location $ProjectRoot + try { + npm install 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Success "Node.js dependencies installed" + } else { + Write-Error-Custom "npm install failed" + $script:Results.Errors += "npm install failed" + } + } finally { + Pop-Location + } +} else { + Write-Warning-Custom "Skipping npm install (Node.js not found)" +} + +Write-Step "Step 6: Installing Python Dependencies" + +if ($kicad) { + Write-Info "Installing Python packages..." + Push-Location $ProjectRoot + try { + $requirementsFile = Join-Path $ProjectRoot "requirements.txt" + if (Test-Path $requirementsFile) { + & $kicad.PythonExe -m pip install -r $requirementsFile 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Success "Python dependencies installed" + $script:Results.DependenciesInstalled = $true + } else { + Write-Warning-Custom "Some Python packages may have failed to install" + } + } else { + Write-Warning-Custom "requirements.txt not found" + } + } finally { + Pop-Location + } +} else { + Write-Warning-Custom "Skipping Python dependencies (KiCAD Python not found)" +} + +Write-Step "Step 7: Building TypeScript Project" + +if (-not $SkipBuild -and $script:Results.NodeFound) { + Write-Info "Running npm run build..." + Push-Location $ProjectRoot + try { + npm run build 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + $distPath = Join-Path $ProjectRoot "dist\index.js" + if (Test-Path $distPath) { + Write-Success "Project built successfully" + $script:Results.ProjectBuilt = $true + } else { + Write-Error-Custom "Build completed but dist/index.js not found" + $script:Results.Errors += "Build output missing" + } + } else { + Write-Error-Custom "Build failed" + $script:Results.Errors += "TypeScript build failed" + } + } finally { + Pop-Location + } +} else { + if ($SkipBuild) { + Write-Info "Skipping build (--SkipBuild specified)" + } else { + Write-Warning-Custom "Skipping build (Node.js not found)" + } +} + +Write-Step "Step 8: Generating Configuration" + +if ($kicad -and $script:Results.ProjectBuilt) { + $distPath = Join-Path $ProjectRoot "dist\index.js" + $distPathEscaped = $distPath -replace '\\', '\\' + $pythonLibEscaped = $kicad.PythonLib -replace '\\', '\\' + + $config = @" +{ + "mcpServers": { + "kicad": { + "command": "node", + "args": ["$distPathEscaped"], + "env": { + "PYTHONPATH": "$pythonLibEscaped", + "NODE_ENV": "production", + "LOG_LEVEL": "info" + } + } + } +} +"@ + + $configPath = Join-Path $ProjectRoot "windows-mcp-config.json" + $config | Out-File -FilePath $configPath -Encoding UTF8 + Write-Success "Configuration generated: $configPath" + $script:Results.ConfigGenerated = $true + + Write-Info "`nConfiguration Preview:" + Write-Host $config -ForegroundColor Gray + + # Provide instructions based on client type + Write-Info "`nTo use this configuration:" + + if ($ClientType -eq 'claude-desktop') { + $claudeConfigPath = "$env:APPDATA\Claude\claude_desktop_config.json" + Write-Host "`n1. Open Claude Desktop configuration:" -ForegroundColor Yellow + Write-Host " $claudeConfigPath" -ForegroundColor White + Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow + Write-Host " $configPath" -ForegroundColor White + Write-Host "`n3. Restart Claude Desktop" -ForegroundColor Yellow + } elseif ($ClientType -eq 'cline') { + $clineConfigPath = "$env:APPDATA\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json" + Write-Host "`n1. Open Cline configuration:" -ForegroundColor Yellow + Write-Host " $clineConfigPath" -ForegroundColor White + Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow + Write-Host " $configPath" -ForegroundColor White + Write-Host "`n3. Restart VSCode" -ForegroundColor Yellow + } else { + Write-Host "`n1. Configuration saved to:" -ForegroundColor Yellow + Write-Host " $configPath" -ForegroundColor White + Write-Host "`n2. Copy to your MCP client configuration" -ForegroundColor Yellow + } + +} else { + Write-Warning-Custom "Skipping configuration generation (prerequisites not met)" +} + +Write-Step "Step 9: Running Diagnostic Test" + +if ($kicad -and $script:Results.ProjectBuilt) { + Write-Info "Testing server startup..." + + $env:PYTHONPATH = $kicad.PythonLib + $distPath = Join-Path $ProjectRoot "dist\index.js" + + # Start the server process + $process = Start-Process -FilePath "node" ` + -ArgumentList $distPath ` + -NoNewWindow ` + -PassThru ` + -RedirectStandardError (Join-Path $env:TEMP "kicad-mcp-test-error.txt") ` + -RedirectStandardOutput (Join-Path $env:TEMP "kicad-mcp-test-output.txt") + + # Wait a moment for startup + Start-Sleep -Seconds 2 + + if (-not $process.HasExited) { + Write-Success "Server started successfully (PID: $($process.Id))" + Write-Info "Stopping test server..." + Stop-Process -Id $process.Id -Force + } else { + Write-Error-Custom "Server exited immediately (exit code: $($process.ExitCode))" + + $errorLog = Join-Path $env:TEMP "kicad-mcp-test-error.txt" + if (Test-Path $errorLog) { + $errorContent = Get-Content $errorLog -Raw + if ($errorContent) { + Write-Warning-Custom "Error output:" + Write-Host $errorContent -ForegroundColor Red + } + } + + $script:Results.Errors += "Server startup test failed" + } +} else { + Write-Warning-Custom "Skipping diagnostic test (prerequisites not met)" +} + +# Final Report +Write-Step "Setup Summary" + +Write-Host "`nComponent Status:" -ForegroundColor Cyan +Write-Host " KiCAD Installation: $(if ($script:Results.KiCADFound) { '[OK] Found' } else { '[ERROR] Not Found' })" -ForegroundColor $(if ($script:Results.KiCADFound) { 'Green' } else { 'Red' }) +if ($script:Results.KiCADVersion) { + Write-Host " Version: $($script:Results.KiCADVersion)" -ForegroundColor Gray +} +Write-Host " pcbnew Module: $(if ($script:Results.PcbnewImport) { '[OK] Working' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.PcbnewImport) { 'Green' } else { 'Red' }) +Write-Host " Node.js: $(if ($script:Results.NodeFound) { '[OK] Found' } else { '[ERROR] Not Found' })" -ForegroundColor $(if ($script:Results.NodeFound) { 'Green' } else { 'Red' }) +if ($script:Results.NodeVersion) { + Write-Host " Version: $($script:Results.NodeVersion)" -ForegroundColor Gray +} +Write-Host " Python Dependencies: $(if ($script:Results.DependenciesInstalled) { '[OK] Installed' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.DependenciesInstalled) { 'Green' } else { 'Red' }) +Write-Host " Project Build: $(if ($script:Results.ProjectBuilt) { '[OK] Success' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.ProjectBuilt) { 'Green' } else { 'Red' }) +Write-Host " Configuration: $(if ($script:Results.ConfigGenerated) { '[OK] Generated' } else { '[ERROR] Not Generated' })" -ForegroundColor $(if ($script:Results.ConfigGenerated) { 'Green' } else { 'Red' }) + +if ($script:Results.Errors.Count -gt 0) { + Write-Host "`nErrors Encountered:" -ForegroundColor Red + foreach ($error in $script:Results.Errors) { + Write-Host " • $error" -ForegroundColor Red + } +} + +# Check for log file +$logPath = "$env:USERPROFILE\.kicad-mcp\logs\kicad_interface.log" +if (Test-Path $logPath) { + Write-Host "`nLog file location:" -ForegroundColor Cyan + Write-Host " $logPath" -ForegroundColor Gray +} + +# Success criteria +$isSuccess = $script:Results.KiCADFound -and + $script:Results.PcbnewImport -and + $script:Results.NodeFound -and + $script:Results.ProjectBuilt + +if ($isSuccess) { + Write-Host "`n============================================================" -ForegroundColor Green + Write-Host " [OK] Setup completed successfully!" -ForegroundColor Green + Write-Host "" -ForegroundColor Green + Write-Host " Next steps:" -ForegroundColor Green + Write-Host " 1. Copy the generated config to your MCP client" -ForegroundColor Green + Write-Host " 2. Restart your MCP client (Claude Desktop/Cline)" -ForegroundColor Green + Write-Host " 3. Try: 'Create a new KiCAD project'" -ForegroundColor Green + Write-Host "============================================================" -ForegroundColor Green +} else { + Write-Host "`n============================================================" -ForegroundColor Red + Write-Host " [ERROR] Setup incomplete - issues detected" -ForegroundColor Red + Write-Host "" -ForegroundColor Red + Write-Host " Please resolve the errors above and run again" -ForegroundColor Red + Write-Host "" -ForegroundColor Red + Write-Host " For help:" -ForegroundColor Red + Write-Host " https://github.com/mixelpixx/KiCAD-MCP-Server/issues" -ForegroundColor Red + Write-Host "============================================================" -ForegroundColor Red + exit 1 +} diff --git a/tests/__init__.py b/tests/__init__.py index c82134b..009b6d8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Tests for KiCAD MCP Server""" +"""Tests for KiCAD MCP Server""" diff --git a/tests/test_platform_helper.py b/tests/test_platform_helper.py index 6a8033e..4fcf0e3 100644 --- a/tests/test_platform_helper.py +++ b/tests/test_platform_helper.py @@ -1,193 +1,193 @@ -""" -Tests for platform_helper utility - -These are unit tests that work on all platforms. -""" - -import os -import platform -import sys -from pathlib import Path - -import pytest - -# Add parent directory to path to import utils -sys.path.insert(0, str(Path(__file__).parent.parent / "python")) - -from utils.platform_helper import PlatformHelper, detect_platform - - -class TestPlatformDetection: - """Test platform detection functions""" - - def test_exactly_one_platform_detected(self): - """Ensure exactly one platform is detected""" - platforms = [ - PlatformHelper.is_windows(), - PlatformHelper.is_linux(), - PlatformHelper.is_macos(), - ] - assert sum(platforms) == 1, "Exactly one platform should be detected" - - def test_platform_name_is_valid(self): - """Test platform name is human-readable""" - name = PlatformHelper.get_platform_name() - assert name in ["Windows", "Linux", "macOS"], f"Unknown platform: {name}" - - def test_platform_name_matches_detection(self): - """Ensure platform name matches detection functions""" - name = PlatformHelper.get_platform_name() - if name == "Windows": - assert PlatformHelper.is_windows() - elif name == "Linux": - assert PlatformHelper.is_linux() - elif name == "macOS": - assert PlatformHelper.is_macos() - - -class TestPathGeneration: - """Test path generation functions""" - - def test_config_dir_exists_after_ensure(self): - """Test that config directory is created""" - PlatformHelper.ensure_directories() - config_dir = PlatformHelper.get_config_dir() - assert config_dir.exists(), f"Config dir should exist: {config_dir}" - assert config_dir.is_dir(), f"Config dir should be a directory: {config_dir}" - - def test_log_dir_exists_after_ensure(self): - """Test that log directory is created""" - PlatformHelper.ensure_directories() - log_dir = PlatformHelper.get_log_dir() - assert log_dir.exists(), f"Log dir should exist: {log_dir}" - assert log_dir.is_dir(), f"Log dir should be a directory: {log_dir}" - - def test_cache_dir_exists_after_ensure(self): - """Test that cache directory is created""" - PlatformHelper.ensure_directories() - cache_dir = PlatformHelper.get_cache_dir() - assert cache_dir.exists(), f"Cache dir should exist: {cache_dir}" - assert cache_dir.is_dir(), f"Cache dir should be a directory: {cache_dir}" - - def test_config_dir_is_platform_appropriate(self): - """Test that config directory follows platform conventions""" - config_dir = PlatformHelper.get_config_dir() - - if PlatformHelper.is_linux(): - # Should be ~/.config/kicad-mcp or $XDG_CONFIG_HOME/kicad-mcp - if "XDG_CONFIG_HOME" in os.environ: - expected = Path(os.environ["XDG_CONFIG_HOME"]) / "kicad-mcp" - else: - expected = Path.home() / ".config" / "kicad-mcp" - assert config_dir == expected - - elif PlatformHelper.is_windows(): - # Should be %USERPROFILE%\.kicad-mcp - expected = Path.home() / ".kicad-mcp" - assert config_dir == expected - - elif PlatformHelper.is_macos(): - # Should be ~/Library/Application Support/kicad-mcp - expected = Path.home() / "Library" / "Application Support" / "kicad-mcp" - assert config_dir == expected - - def test_config_dir_ignores_relative_xdg_config_home(self, monkeypatch): - """Relative XDG_CONFIG_HOME should be ignored on Linux.""" - monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True)) - monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False)) - monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False)) - monkeypatch.setenv("XDG_CONFIG_HOME", "relative/path") - - assert PlatformHelper.get_config_dir() == Path.home() / ".config" / "kicad-mcp" - - def test_cache_dir_ignores_relative_xdg_cache_home(self, monkeypatch): - """Relative XDG_CACHE_HOME should be ignored on Linux.""" - monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True)) - monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False)) - monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False)) - monkeypatch.setenv("XDG_CACHE_HOME", "relative/cache") - - assert PlatformHelper.get_cache_dir() == Path.home() / ".cache" / "kicad-mcp" - - def test_python_executable_is_valid(self): - """Test that Python executable path is valid""" - exe = PlatformHelper.get_python_executable() - assert exe.exists(), f"Python executable should exist: {exe}" - assert str(exe) == sys.executable - - def test_kicad_library_search_paths_returns_list(self): - """Test that library search paths returns a list""" - paths = PlatformHelper.get_kicad_library_search_paths() - assert isinstance(paths, list) - assert len(paths) > 0 - # All paths should be strings (glob patterns) - assert all(isinstance(p, str) for p in paths) - - -class TestDetectPlatform: - """Test the detect_platform convenience function""" - - def test_detect_platform_returns_dict(self): - """Test that detect_platform returns a dictionary""" - info = detect_platform() - assert isinstance(info, dict) - - def test_detect_platform_has_required_keys(self): - """Test that detect_platform includes all required keys""" - info = detect_platform() - required_keys = [ - "system", - "platform", - "is_windows", - "is_linux", - "is_macos", - "python_version", - "python_executable", - "config_dir", - "log_dir", - "cache_dir", - "kicad_python_paths", - ] - for key in required_keys: - assert key in info, f"Missing key: {key}" - - def test_detect_platform_python_version_format(self): - """Test that Python version is in correct format""" - info = detect_platform() - version = info["python_version"] - # Should be like "3.12.3" - parts = version.split(".") - assert len(parts) == 3 - assert all(p.isdigit() for p in parts) - - -@pytest.mark.integration -class TestKiCADPathDetection: - """Tests that require KiCAD to be installed""" - - def test_kicad_python_paths_exist(self): - """Test that at least one KiCAD Python path exists (if KiCAD is installed)""" - paths = PlatformHelper.get_kicad_python_paths() - # This test only makes sense if KiCAD is installed - # In CI, KiCAD should be installed - if paths: - assert all(p.exists() for p in paths), "All returned paths should exist" - - def test_can_import_pcbnew_after_adding_paths(self): - """Test that pcbnew can be imported after adding KiCAD paths""" - PlatformHelper.add_kicad_to_python_path() - try: - import pcbnew - - # If we get here, pcbnew is available - assert pcbnew is not None - version = pcbnew.GetBuildVersion() - assert version is not None - print(f"Found KiCAD version: {version}") - except ImportError: - pytest.skip("KiCAD pcbnew module not available (KiCAD not installed)") - - -if __name__ == "__main__": - # Run tests with pytest - pytest.main([__file__, "-v"]) +""" +Tests for platform_helper utility + +These are unit tests that work on all platforms. +""" + +import os +import platform +import sys +from pathlib import Path + +import pytest + +# Add parent directory to path to import utils +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +from utils.platform_helper import PlatformHelper, detect_platform + + +class TestPlatformDetection: + """Test platform detection functions""" + + def test_exactly_one_platform_detected(self): + """Ensure exactly one platform is detected""" + platforms = [ + PlatformHelper.is_windows(), + PlatformHelper.is_linux(), + PlatformHelper.is_macos(), + ] + assert sum(platforms) == 1, "Exactly one platform should be detected" + + def test_platform_name_is_valid(self): + """Test platform name is human-readable""" + name = PlatformHelper.get_platform_name() + assert name in ["Windows", "Linux", "macOS"], f"Unknown platform: {name}" + + def test_platform_name_matches_detection(self): + """Ensure platform name matches detection functions""" + name = PlatformHelper.get_platform_name() + if name == "Windows": + assert PlatformHelper.is_windows() + elif name == "Linux": + assert PlatformHelper.is_linux() + elif name == "macOS": + assert PlatformHelper.is_macos() + + +class TestPathGeneration: + """Test path generation functions""" + + def test_config_dir_exists_after_ensure(self): + """Test that config directory is created""" + PlatformHelper.ensure_directories() + config_dir = PlatformHelper.get_config_dir() + assert config_dir.exists(), f"Config dir should exist: {config_dir}" + assert config_dir.is_dir(), f"Config dir should be a directory: {config_dir}" + + def test_log_dir_exists_after_ensure(self): + """Test that log directory is created""" + PlatformHelper.ensure_directories() + log_dir = PlatformHelper.get_log_dir() + assert log_dir.exists(), f"Log dir should exist: {log_dir}" + assert log_dir.is_dir(), f"Log dir should be a directory: {log_dir}" + + def test_cache_dir_exists_after_ensure(self): + """Test that cache directory is created""" + PlatformHelper.ensure_directories() + cache_dir = PlatformHelper.get_cache_dir() + assert cache_dir.exists(), f"Cache dir should exist: {cache_dir}" + assert cache_dir.is_dir(), f"Cache dir should be a directory: {cache_dir}" + + def test_config_dir_is_platform_appropriate(self): + """Test that config directory follows platform conventions""" + config_dir = PlatformHelper.get_config_dir() + + if PlatformHelper.is_linux(): + # Should be ~/.config/kicad-mcp or $XDG_CONFIG_HOME/kicad-mcp + if "XDG_CONFIG_HOME" in os.environ: + expected = Path(os.environ["XDG_CONFIG_HOME"]) / "kicad-mcp" + else: + expected = Path.home() / ".config" / "kicad-mcp" + assert config_dir == expected + + elif PlatformHelper.is_windows(): + # Should be %USERPROFILE%\.kicad-mcp + expected = Path.home() / ".kicad-mcp" + assert config_dir == expected + + elif PlatformHelper.is_macos(): + # Should be ~/Library/Application Support/kicad-mcp + expected = Path.home() / "Library" / "Application Support" / "kicad-mcp" + assert config_dir == expected + + def test_config_dir_ignores_relative_xdg_config_home(self, monkeypatch): + """Relative XDG_CONFIG_HOME should be ignored on Linux.""" + monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True)) + monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False)) + monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False)) + monkeypatch.setenv("XDG_CONFIG_HOME", "relative/path") + + assert PlatformHelper.get_config_dir() == Path.home() / ".config" / "kicad-mcp" + + def test_cache_dir_ignores_relative_xdg_cache_home(self, monkeypatch): + """Relative XDG_CACHE_HOME should be ignored on Linux.""" + monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True)) + monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False)) + monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False)) + monkeypatch.setenv("XDG_CACHE_HOME", "relative/cache") + + assert PlatformHelper.get_cache_dir() == Path.home() / ".cache" / "kicad-mcp" + + def test_python_executable_is_valid(self): + """Test that Python executable path is valid""" + exe = PlatformHelper.get_python_executable() + assert exe.exists(), f"Python executable should exist: {exe}" + assert str(exe) == sys.executable + + def test_kicad_library_search_paths_returns_list(self): + """Test that library search paths returns a list""" + paths = PlatformHelper.get_kicad_library_search_paths() + assert isinstance(paths, list) + assert len(paths) > 0 + # All paths should be strings (glob patterns) + assert all(isinstance(p, str) for p in paths) + + +class TestDetectPlatform: + """Test the detect_platform convenience function""" + + def test_detect_platform_returns_dict(self): + """Test that detect_platform returns a dictionary""" + info = detect_platform() + assert isinstance(info, dict) + + def test_detect_platform_has_required_keys(self): + """Test that detect_platform includes all required keys""" + info = detect_platform() + required_keys = [ + "system", + "platform", + "is_windows", + "is_linux", + "is_macos", + "python_version", + "python_executable", + "config_dir", + "log_dir", + "cache_dir", + "kicad_python_paths", + ] + for key in required_keys: + assert key in info, f"Missing key: {key}" + + def test_detect_platform_python_version_format(self): + """Test that Python version is in correct format""" + info = detect_platform() + version = info["python_version"] + # Should be like "3.12.3" + parts = version.split(".") + assert len(parts) == 3 + assert all(p.isdigit() for p in parts) + + +@pytest.mark.integration +class TestKiCADPathDetection: + """Tests that require KiCAD to be installed""" + + def test_kicad_python_paths_exist(self): + """Test that at least one KiCAD Python path exists (if KiCAD is installed)""" + paths = PlatformHelper.get_kicad_python_paths() + # This test only makes sense if KiCAD is installed + # In CI, KiCAD should be installed + if paths: + assert all(p.exists() for p in paths), "All returned paths should exist" + + def test_can_import_pcbnew_after_adding_paths(self): + """Test that pcbnew can be imported after adding KiCAD paths""" + PlatformHelper.add_kicad_to_python_path() + try: + import pcbnew + + # If we get here, pcbnew is available + assert pcbnew is not None + version = pcbnew.GetBuildVersion() + assert version is not None + print(f"Found KiCAD version: {version}") + except ImportError: + pytest.skip("KiCAD pcbnew module not available (KiCAD not installed)") + + +if __name__ == "__main__": + # Run tests with pytest + pytest.main([__file__, "-v"])