This is page 4 of 6. Use http://codebase.md/mixelpixx/kicad-mcp-server?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .github
│ └── workflows
│ └── ci.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CHANGELOG_2025-10-26.md
├── CHANGELOG_2025-11-01.md
├── CHANGELOG_2025-11-05.md
├── CHANGELOG_2025-11-30.md
├── config
│ ├── claude-desktop-config.json
│ ├── default-config.json
│ ├── linux-config.example.json
│ ├── macos-config.example.json
│ └── windows-config.example.json
├── CONTRIBUTING.md
├── docs
│ ├── BUILD_AND_TEST_SESSION.md
│ ├── CLIENT_CONFIGURATION.md
│ ├── IPC_API_MIGRATION_PLAN.md
│ ├── IPC_BACKEND_STATUS.md
│ ├── JLCPCB_INTEGRATION_PLAN.md
│ ├── KNOWN_ISSUES.md
│ ├── LIBRARY_INTEGRATION.md
│ ├── LINUX_COMPATIBILITY_AUDIT.md
│ ├── PLATFORM_GUIDE.md
│ ├── REALTIME_WORKFLOW.md
│ ├── ROADMAP.md
│ ├── STATUS_SUMMARY.md
│ ├── UI_AUTO_LAUNCH.md
│ ├── VISUAL_FEEDBACK.md
│ ├── WEEK1_SESSION1_SUMMARY.md
│ ├── WEEK1_SESSION2_SUMMARY.md
│ └── WINDOWS_TROUBLESHOOTING.md
├── LICENSE
├── package-json.json
├── package-lock.json
├── package.json
├── pytest.ini
├── python
│ ├── commands
│ │ ├── __init__.py
│ │ ├── board
│ │ │ ├── __init__.py
│ │ │ ├── layers.py
│ │ │ ├── outline.py
│ │ │ ├── size.py
│ │ │ └── view.py
│ │ ├── board.py
│ │ ├── component_schematic.py
│ │ ├── component.py
│ │ ├── connection_schematic.py
│ │ ├── design_rules.py
│ │ ├── export.py
│ │ ├── library_schematic.py
│ │ ├── library.py
│ │ ├── project.py
│ │ ├── routing.py
│ │ └── schematic.py
│ ├── kicad_api
│ │ ├── __init__.py
│ │ ├── base.py
│ │ ├── factory.py
│ │ ├── ipc_backend.py
│ │ └── swig_backend.py
│ ├── kicad_interface.py
│ ├── requirements.txt
│ ├── resources
│ │ ├── __init__.py
│ │ └── resource_definitions.py
│ ├── schemas
│ │ ├── __init__.py
│ │ └── tool_schemas.py
│ ├── test_ipc_backend.py
│ └── utils
│ ├── __init__.py
│ ├── kicad_process.py
│ └── platform_helper.py
├── README.md
├── requirements-dev.txt
├── requirements.txt
├── scripts
│ ├── auto_refresh_kicad.sh
│ └── install-linux.sh
├── setup-windows.ps1
├── src
│ ├── config.ts
│ ├── index.ts
│ ├── kicad-server.ts
│ ├── logger.ts
│ ├── prompts
│ │ ├── component.ts
│ │ ├── design.ts
│ │ ├── index.ts
│ │ └── routing.ts
│ ├── resources
│ │ ├── board.ts
│ │ ├── component.ts
│ │ ├── index.ts
│ │ ├── library.ts
│ │ └── project.ts
│ ├── server.ts
│ ├── tools
│ │ ├── board.ts
│ │ ├── component.ts
│ │ ├── component.txt
│ │ ├── design-rules.ts
│ │ ├── export.ts
│ │ ├── index.ts
│ │ ├── library.ts
│ │ ├── project.ts
│ │ ├── routing.ts
│ │ ├── schematic.ts
│ │ └── ui.ts
│ └── utils
│ └── resource-helpers.ts
├── tests
│ ├── __init__.py
│ └── test_platform_helper.py
└── tsconfig.json
```
# Files
--------------------------------------------------------------------------------
/python/commands/connection_schematic.py:
--------------------------------------------------------------------------------
```python
1 | from skip import Schematic
2 | import os
3 | import logging
4 |
5 | logger = logging.getLogger(__name__)
6 |
7 | class ConnectionManager:
8 | """Manage connections between components in schematics"""
9 |
10 | @staticmethod
11 | def add_wire(schematic: Schematic, start_point: list, end_point: list, properties: dict = None):
12 | """
13 | Add a wire between two points
14 |
15 | Args:
16 | schematic: Schematic object
17 | start_point: [x, y] coordinates for wire start
18 | end_point: [x, y] coordinates for wire end
19 | properties: Optional wire properties (currently unused)
20 |
21 | Returns:
22 | Wire object or None on error
23 | """
24 | try:
25 | # Check if wire collection exists
26 | if not hasattr(schematic, 'wire'):
27 | logger.error("Schematic does not have wire collection")
28 | return None
29 |
30 | wire = schematic.wire.append(
31 | start={'x': start_point[0], 'y': start_point[1]},
32 | end={'x': end_point[0], 'y': end_point[1]}
33 | )
34 | logger.info(f"Added wire from {start_point} to {end_point}")
35 | return wire
36 | except Exception as e:
37 | logger.error(f"Error adding wire: {e}")
38 | return None
39 |
40 | @staticmethod
41 | def get_pin_location(symbol, pin_name: str):
42 | """
43 | Get the absolute location of a pin on a symbol
44 |
45 | Args:
46 | symbol: Symbol object
47 | pin_name: Name or number of the pin (e.g., "1", "GND", "VCC")
48 |
49 | Returns:
50 | [x, y] coordinates or None if pin not found
51 | """
52 | try:
53 | if not hasattr(symbol, 'pin'):
54 | logger.warning(f"Symbol {symbol.property.Reference.value} has no pins")
55 | return None
56 |
57 | # Find the pin by name
58 | target_pin = None
59 | for pin in symbol.pin:
60 | if pin.name == pin_name:
61 | target_pin = pin
62 | break
63 |
64 | if not target_pin:
65 | logger.warning(f"Pin '{pin_name}' not found on {symbol.property.Reference.value}")
66 | return None
67 |
68 | # Get pin location relative to symbol
69 | pin_loc = target_pin.location
70 | # Get symbol location
71 | symbol_at = symbol.at.value
72 |
73 | # Calculate absolute position
74 | # pin_loc is relative to symbol origin, need to add symbol position
75 | abs_x = symbol_at[0] + pin_loc[0]
76 | abs_y = symbol_at[1] + pin_loc[1]
77 |
78 | return [abs_x, abs_y]
79 | except Exception as e:
80 | logger.error(f"Error getting pin location: {e}")
81 | return None
82 |
83 | @staticmethod
84 | def add_connection(schematic: Schematic, source_ref: str, source_pin: str, target_ref: str, target_pin: str):
85 | """
86 | Add a wire connection between two component pins
87 |
88 | Args:
89 | schematic: Schematic object
90 | source_ref: Reference designator of source component (e.g., "R1")
91 | source_pin: Pin name/number on source component
92 | target_ref: Reference designator of target component (e.g., "C1")
93 | target_pin: Pin name/number on target component
94 |
95 | Returns:
96 | True if connection was successful, False otherwise
97 | """
98 | try:
99 | # Find source and target symbols
100 | source_symbol = None
101 | target_symbol = None
102 |
103 | if not hasattr(schematic, 'symbol'):
104 | logger.error("Schematic has no symbols")
105 | return False
106 |
107 | for symbol in schematic.symbol:
108 | ref = symbol.property.Reference.value
109 | if ref == source_ref:
110 | source_symbol = symbol
111 | if ref == target_ref:
112 | target_symbol = symbol
113 |
114 | if not source_symbol:
115 | logger.error(f"Source component '{source_ref}' not found")
116 | return False
117 |
118 | if not target_symbol:
119 | logger.error(f"Target component '{target_ref}' not found")
120 | return False
121 |
122 | # Get pin locations
123 | source_loc = ConnectionManager.get_pin_location(source_symbol, source_pin)
124 | target_loc = ConnectionManager.get_pin_location(target_symbol, target_pin)
125 |
126 | if not source_loc or not target_loc:
127 | logger.error("Could not determine pin locations")
128 | return False
129 |
130 | # Add wire between pins
131 | wire = ConnectionManager.add_wire(schematic, source_loc, target_loc)
132 |
133 | if wire:
134 | logger.info(f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin}")
135 | return True
136 | else:
137 | return False
138 |
139 | except Exception as e:
140 | logger.error(f"Error adding connection: {e}")
141 | return False
142 |
143 | @staticmethod
144 | def add_net_label(schematic: Schematic, net_name: str, position: list):
145 | """
146 | Add a net label to the schematic
147 |
148 | Args:
149 | schematic: Schematic object
150 | net_name: Name of the net (e.g., "VCC", "GND", "SIGNAL_1")
151 | position: [x, y] coordinates for the label
152 |
153 | Returns:
154 | Label object or None on error
155 | """
156 | try:
157 | if not hasattr(schematic, 'label'):
158 | logger.error("Schematic does not have label collection")
159 | return None
160 |
161 | label = schematic.label.append(
162 | text=net_name,
163 | at={'x': position[0], 'y': position[1]}
164 | )
165 | logger.info(f"Added net label '{net_name}' at {position}")
166 | return label
167 | except Exception as e:
168 | logger.error(f"Error adding net label: {e}")
169 | return None
170 |
171 | @staticmethod
172 | def connect_to_net(schematic: Schematic, component_ref: str, pin_name: str, net_name: str):
173 | """
174 | Connect a component pin to a named net using a label
175 |
176 | Args:
177 | schematic: Schematic object
178 | component_ref: Reference designator (e.g., "U1")
179 | pin_name: Pin name/number
180 | net_name: Name of the net to connect to
181 |
182 | Returns:
183 | True if successful, False otherwise
184 | """
185 | try:
186 | # Find the component
187 | symbol = None
188 | if hasattr(schematic, 'symbol'):
189 | for s in schematic.symbol:
190 | if s.property.Reference.value == component_ref:
191 | symbol = s
192 | break
193 |
194 | if not symbol:
195 | logger.error(f"Component '{component_ref}' not found")
196 | return False
197 |
198 | # Get pin location
199 | pin_loc = ConnectionManager.get_pin_location(symbol, pin_name)
200 | if not pin_loc:
201 | return False
202 |
203 | # Add a small wire stub from the pin (so label has something to attach to)
204 | stub_end = [pin_loc[0] + 2.54, pin_loc[1]] # 2.54mm = 0.1 inch grid
205 | wire = ConnectionManager.add_wire(schematic, pin_loc, stub_end)
206 |
207 | if not wire:
208 | return False
209 |
210 | # Add label at the end of the stub
211 | label = ConnectionManager.add_net_label(schematic, net_name, stub_end)
212 |
213 | if label:
214 | logger.info(f"Connected {component_ref}/{pin_name} to net '{net_name}'")
215 | return True
216 | else:
217 | return False
218 |
219 | except Exception as e:
220 | logger.error(f"Error connecting to net: {e}")
221 | return False
222 |
223 | @staticmethod
224 | def get_net_connections(schematic: Schematic, net_name: str):
225 | """
226 | Get all connections for a named net
227 |
228 | Args:
229 | schematic: Schematic object
230 | net_name: Name of the net to query
231 |
232 | Returns:
233 | List of connections: [{"component": ref, "pin": pin_name}, ...]
234 | """
235 | try:
236 | connections = []
237 |
238 | if not hasattr(schematic, 'label'):
239 | logger.warning("Schematic has no labels")
240 | return connections
241 |
242 | # Find all labels with this net name
243 | net_labels = []
244 | for label in schematic.label:
245 | if hasattr(label, 'value') and label.value == net_name:
246 | net_labels.append(label)
247 |
248 | if not net_labels:
249 | logger.info(f"No labels found for net '{net_name}'")
250 | return connections
251 |
252 | # For each label, find connected symbols
253 | for label in net_labels:
254 | # Find wires connected to this label position
255 | label_pos = label.at.value if hasattr(label, 'at') else None
256 | if not label_pos:
257 | continue
258 |
259 | # Search for symbols near this label
260 | if hasattr(schematic, 'symbol'):
261 | for symbol in schematic.symbol:
262 | # Check if symbol has wires attached
263 | if hasattr(symbol, 'attached_labels'):
264 | for attached_label in symbol.attached_labels:
265 | if attached_label.value == net_name:
266 | # Find which pin is connected
267 | if hasattr(symbol, 'pin'):
268 | for pin in symbol.pin:
269 | pin_loc = ConnectionManager.get_pin_location(symbol, pin.name)
270 | if pin_loc:
271 | # Check if pin is connected to any wire attached to this label
272 | connections.append({
273 | "component": symbol.property.Reference.value,
274 | "pin": pin.name
275 | })
276 |
277 | logger.info(f"Found {len(connections)} connections for net '{net_name}'")
278 | return connections
279 |
280 | except Exception as e:
281 | logger.error(f"Error getting net connections: {e}")
282 | return []
283 |
284 | @staticmethod
285 | def generate_netlist(schematic: Schematic):
286 | """
287 | Generate a netlist from the schematic
288 |
289 | Returns:
290 | Dictionary with net information:
291 | {
292 | "nets": [
293 | {
294 | "name": "VCC",
295 | "connections": [
296 | {"component": "R1", "pin": "1"},
297 | {"component": "C1", "pin": "1"}
298 | ]
299 | },
300 | ...
301 | ],
302 | "components": [
303 | {"reference": "R1", "value": "10k", "footprint": "..."},
304 | ...
305 | ]
306 | }
307 | """
308 | try:
309 | netlist = {
310 | "nets": [],
311 | "components": []
312 | }
313 |
314 | # Gather all components
315 | if hasattr(schematic, 'symbol'):
316 | for symbol in schematic.symbol:
317 | component_info = {
318 | "reference": symbol.property.Reference.value,
319 | "value": symbol.property.Value.value if hasattr(symbol.property, 'Value') else "",
320 | "footprint": symbol.property.Footprint.value if hasattr(symbol.property, 'Footprint') else ""
321 | }
322 | netlist["components"].append(component_info)
323 |
324 | # Gather all nets from labels
325 | if hasattr(schematic, 'label'):
326 | net_names = set()
327 | for label in schematic.label:
328 | if hasattr(label, 'value'):
329 | net_names.add(label.value)
330 |
331 | # For each net, get connections
332 | for net_name in net_names:
333 | connections = ConnectionManager.get_net_connections(schematic, net_name)
334 | if connections:
335 | netlist["nets"].append({
336 | "name": net_name,
337 | "connections": connections
338 | })
339 |
340 | logger.info(f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components")
341 | return netlist
342 |
343 | except Exception as e:
344 | logger.error(f"Error generating netlist: {e}")
345 | return {"nets": [], "components": []}
346 |
347 | if __name__ == '__main__':
348 | # Example Usage (for testing)
349 | from schematic import SchematicManager # Assuming schematic.py is in the same directory
350 |
351 | # Create a new schematic
352 | test_sch = SchematicManager.create_schematic("ConnectionTestSchematic")
353 |
354 | # Add some wires
355 | wire1 = ConnectionManager.add_wire(test_sch, [100, 100], [200, 100])
356 | wire2 = ConnectionManager.add_wire(test_sch, [200, 100], [200, 200])
357 |
358 | # Note: add_connection, remove_connection, get_net_connections are placeholders
359 | # and require more complex implementation based on kicad-skip's structure.
360 |
361 | # Example of how you might add a net label (requires finding a point on a wire)
362 | # from skip import Label
363 | # if wire1:
364 | # net_label_pos = wire1.start # Or calculate a point on the wire
365 | # net_label = test_sch.add_label(text="Net_01", at=net_label_pos)
366 | # print(f"Added net label 'Net_01' at {net_label_pos}")
367 |
368 | # Save the schematic (optional)
369 | # SchematicManager.save_schematic(test_sch, "connection_test.kicad_sch")
370 |
371 | # Clean up (if saved)
372 | # if os.path.exists("connection_test.kicad_sch"):
373 | # os.remove("connection_test.kicad_sch")
374 | # print("Cleaned up connection_test.kicad_sch")
375 |
```
--------------------------------------------------------------------------------
/setup-windows.ps1:
--------------------------------------------------------------------------------
```
1 | <#
2 | .SYNOPSIS
3 | KiCAD MCP Server - Windows Setup and Configuration Script
4 |
5 | .DESCRIPTION
6 | This script automates the setup of KiCAD MCP Server on Windows by:
7 | - Detecting KiCAD installation and version
8 | - Verifying Python and Node.js installations
9 | - Testing KiCAD Python module (pcbnew)
10 | - Installing required Python dependencies
11 | - Building the TypeScript project
12 | - Generating Claude Desktop configuration
13 | - Running diagnostic tests
14 |
15 | .PARAMETER SkipBuild
16 | Skip the npm build step (useful if already built)
17 |
18 | .PARAMETER ClientType
19 | Type of MCP client to configure: 'claude-desktop', 'cline', or 'manual'
20 | Default: 'claude-desktop'
21 |
22 | .EXAMPLE
23 | .\setup-windows.ps1
24 | Run the full setup with default options
25 |
26 | .EXAMPLE
27 | .\setup-windows.ps1 -ClientType cline
28 | Setup for Cline VSCode extension
29 |
30 | .EXAMPLE
31 | .\setup-windows.ps1 -SkipBuild
32 | Run setup without rebuilding the project
33 | #>
34 |
35 | param(
36 | [switch]$SkipBuild,
37 | [ValidateSet('claude-desktop', 'cline', 'manual')]
38 | [string]$ClientType = 'claude-desktop'
39 | )
40 |
41 | # Color output helpers
42 | function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green }
43 | function Write-Error-Custom { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red }
44 | function Write-Warning-Custom { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow }
45 | function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan }
46 | function Write-Step { param([string]$Message) Write-Host "`n=== $Message ===" -ForegroundColor Magenta }
47 |
48 | Write-Host @"
49 | ╔════════════════════════════════════════════════════════════╗
50 | ║ KiCAD MCP Server - Windows Setup Script ║
51 | ║ ║
52 | ║ This script will configure KiCAD MCP for Windows ║
53 | ╚════════════════════════════════════════════════════════════╝
54 | "@ -ForegroundColor Cyan
55 |
56 | # Store results for final report
57 | $script:Results = @{
58 | KiCADFound = $false
59 | KiCADVersion = ""
60 | KiCADPythonPath = ""
61 | PythonFound = $false
62 | PythonVersion = ""
63 | NodeFound = $false
64 | NodeVersion = ""
65 | PcbnewImport = $false
66 | DependenciesInstalled = $false
67 | ProjectBuilt = $false
68 | ConfigGenerated = $false
69 | Errors = @()
70 | }
71 |
72 | # Get script directory (project root)
73 | $ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
74 |
75 | Write-Step "Step 1: Detecting KiCAD Installation"
76 |
77 | # Function to find KiCAD installation
78 | function Find-KiCAD {
79 | $possiblePaths = @(
80 | "C:\Program Files\KiCad",
81 | "C:\Program Files (x86)\KiCad"
82 | "$env:USERPROFILE\AppData\Local\Programs\KiCad"
83 | )
84 |
85 | $versions = @("9.0", "9.1", "10.0", "8.0")
86 |
87 | foreach ($basePath in $possiblePaths) {
88 | foreach ($version in $versions) {
89 | $kicadPath = Join-Path $basePath $version
90 | $pythonExe = Join-Path $kicadPath "bin\python.exe"
91 | $pythonLib = Join-Path $kicadPath "lib\python3\dist-packages"
92 |
93 | if (Test-Path $pythonExe) {
94 | Write-Success "Found KiCAD $version at: $kicadPath"
95 | return @{
96 | Path = $kicadPath
97 | Version = $version
98 | PythonExe = $pythonExe
99 | PythonLib = $pythonLib
100 | }
101 | }
102 | }
103 | }
104 |
105 | return $null
106 | }
107 |
108 | $kicad = Find-KiCAD
109 |
110 | if ($kicad) {
111 | $script:Results.KiCADFound = $true
112 | $script:Results.KiCADVersion = $kicad.Version
113 | $script:Results.KiCADPythonPath = $kicad.PythonLib
114 | Write-Info "KiCAD Version: $($kicad.Version)"
115 | Write-Info "Python Path: $($kicad.PythonLib)"
116 | } else {
117 | Write-Error-Custom "KiCAD not found in standard locations"
118 | Write-Warning-Custom "Checked: C:\Program Files, C:\Program Files (x86), and $env:USERPROFILE\AppData\Local\Programs"
119 | Write-Warning-Custom "Please install KiCAD 9.0+ from https://www.kicad.org/download/windows/"
120 | $script:Results.Errors += "KiCAD not found"
121 | }
122 |
123 | Write-Step "Step 2: Checking Node.js Installation"
124 |
125 | try {
126 | $nodeVersion = node --version 2>$null
127 | if ($LASTEXITCODE -eq 0) {
128 | Write-Success "Node.js found: $nodeVersion"
129 | $script:Results.NodeFound = $true
130 | $script:Results.NodeVersion = $nodeVersion
131 |
132 | # Check if version is 18+
133 | $versionNumber = [int]($nodeVersion -replace 'v(\d+)\..*', '$1')
134 | if ($versionNumber -lt 18) {
135 | Write-Warning-Custom "Node.js version 18+ is recommended (you have $nodeVersion)"
136 | }
137 | }
138 | } catch {
139 | Write-Error-Custom "Node.js not found"
140 | Write-Warning-Custom "Please install Node.js 18+ from https://nodejs.org/"
141 | $script:Results.Errors += "Node.js not found"
142 | }
143 |
144 | Write-Step "Step 3: Testing KiCAD Python Module"
145 |
146 | if ($kicad) {
147 | Write-Info "Testing pcbnew module import..."
148 |
149 | $testScript = "import sys; import pcbnew; print(f'SUCCESS:{pcbnew.GetBuildVersion()}')"
150 | $result = & $kicad.PythonExe -c $testScript 2>&1
151 |
152 | if ($result -match "SUCCESS:(.+)") {
153 | $pcbnewVersion = $matches[1]
154 | Write-Success "pcbnew module imported successfully: $pcbnewVersion"
155 | $script:Results.PcbnewImport = $true
156 | } else {
157 | Write-Error-Custom "Failed to import pcbnew module"
158 | Write-Warning-Custom "Error: $result"
159 | Write-Info "This usually means KiCAD was not installed with Python support"
160 | $script:Results.Errors += "pcbnew import failed: $result"
161 | }
162 | } else {
163 | Write-Warning-Custom "Skipping pcbnew test (KiCAD not found)"
164 | }
165 |
166 | Write-Step "Step 4: Checking Python Installation"
167 |
168 | try {
169 | $pythonVersion = python --version 2>&1
170 | if ($pythonVersion -match "Python (\d+\.\d+\.\d+)") {
171 | Write-Success "System Python found: $pythonVersion"
172 | $script:Results.PythonFound = $true
173 | $script:Results.PythonVersion = $pythonVersion
174 | }
175 | } catch {
176 | Write-Warning-Custom "System Python not found (using KiCAD's Python)"
177 | }
178 |
179 | Write-Step "Step 5: Installing Node.js Dependencies"
180 |
181 | if ($script:Results.NodeFound) {
182 | Write-Info "Running npm install..."
183 | Push-Location $ProjectRoot
184 | try {
185 | npm install 2>&1 | Out-Null
186 | if ($LASTEXITCODE -eq 0) {
187 | Write-Success "Node.js dependencies installed"
188 | } else {
189 | Write-Error-Custom "npm install failed"
190 | $script:Results.Errors += "npm install failed"
191 | }
192 | } finally {
193 | Pop-Location
194 | }
195 | } else {
196 | Write-Warning-Custom "Skipping npm install (Node.js not found)"
197 | }
198 |
199 | Write-Step "Step 6: Installing Python Dependencies"
200 |
201 | if ($kicad) {
202 | Write-Info "Installing Python packages..."
203 | Push-Location $ProjectRoot
204 | try {
205 | $requirementsFile = Join-Path $ProjectRoot "requirements.txt"
206 | if (Test-Path $requirementsFile) {
207 | & $kicad.PythonExe -m pip install -r $requirementsFile 2>&1 | Out-Null
208 | if ($LASTEXITCODE -eq 0) {
209 | Write-Success "Python dependencies installed"
210 | $script:Results.DependenciesInstalled = $true
211 | } else {
212 | Write-Warning-Custom "Some Python packages may have failed to install"
213 | }
214 | } else {
215 | Write-Warning-Custom "requirements.txt not found"
216 | }
217 | } finally {
218 | Pop-Location
219 | }
220 | } else {
221 | Write-Warning-Custom "Skipping Python dependencies (KiCAD Python not found)"
222 | }
223 |
224 | Write-Step "Step 7: Building TypeScript Project"
225 |
226 | if (-not $SkipBuild -and $script:Results.NodeFound) {
227 | Write-Info "Running npm run build..."
228 | Push-Location $ProjectRoot
229 | try {
230 | npm run build 2>&1 | Out-Null
231 | if ($LASTEXITCODE -eq 0) {
232 | $distPath = Join-Path $ProjectRoot "dist\index.js"
233 | if (Test-Path $distPath) {
234 | Write-Success "Project built successfully"
235 | $script:Results.ProjectBuilt = $true
236 | } else {
237 | Write-Error-Custom "Build completed but dist/index.js not found"
238 | $script:Results.Errors += "Build output missing"
239 | }
240 | } else {
241 | Write-Error-Custom "Build failed"
242 | $script:Results.Errors += "TypeScript build failed"
243 | }
244 | } finally {
245 | Pop-Location
246 | }
247 | } else {
248 | if ($SkipBuild) {
249 | Write-Info "Skipping build (--SkipBuild specified)"
250 | } else {
251 | Write-Warning-Custom "Skipping build (Node.js not found)"
252 | }
253 | }
254 |
255 | Write-Step "Step 8: Generating Configuration"
256 |
257 | if ($kicad -and $script:Results.ProjectBuilt) {
258 | $distPath = Join-Path $ProjectRoot "dist\index.js"
259 | $distPathEscaped = $distPath -replace '\\', '\\'
260 | $pythonLibEscaped = $kicad.PythonLib -replace '\\', '\\'
261 |
262 | $config = @"
263 | {
264 | "mcpServers": {
265 | "kicad": {
266 | "command": "node",
267 | "args": ["$distPathEscaped"],
268 | "env": {
269 | "PYTHONPATH": "$pythonLibEscaped",
270 | "NODE_ENV": "production",
271 | "LOG_LEVEL": "info"
272 | }
273 | }
274 | }
275 | }
276 | "@
277 |
278 | $configPath = Join-Path $ProjectRoot "windows-mcp-config.json"
279 | $config | Out-File -FilePath $configPath -Encoding UTF8
280 | Write-Success "Configuration generated: $configPath"
281 | $script:Results.ConfigGenerated = $true
282 |
283 | Write-Info "`nConfiguration Preview:"
284 | Write-Host $config -ForegroundColor Gray
285 |
286 | # Provide instructions based on client type
287 | Write-Info "`nTo use this configuration:"
288 |
289 | if ($ClientType -eq 'claude-desktop') {
290 | $claudeConfigPath = "$env:APPDATA\Claude\claude_desktop_config.json"
291 | Write-Host "`n1. Open Claude Desktop configuration:" -ForegroundColor Yellow
292 | Write-Host " $claudeConfigPath" -ForegroundColor White
293 | Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow
294 | Write-Host " $configPath" -ForegroundColor White
295 | Write-Host "`n3. Restart Claude Desktop" -ForegroundColor Yellow
296 | } elseif ($ClientType -eq 'cline') {
297 | $clineConfigPath = "$env:APPDATA\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json"
298 | Write-Host "`n1. Open Cline configuration:" -ForegroundColor Yellow
299 | Write-Host " $clineConfigPath" -ForegroundColor White
300 | Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow
301 | Write-Host " $configPath" -ForegroundColor White
302 | Write-Host "`n3. Restart VSCode" -ForegroundColor Yellow
303 | } else {
304 | Write-Host "`n1. Configuration saved to:" -ForegroundColor Yellow
305 | Write-Host " $configPath" -ForegroundColor White
306 | Write-Host "`n2. Copy to your MCP client configuration" -ForegroundColor Yellow
307 | }
308 |
309 | } else {
310 | Write-Warning-Custom "Skipping configuration generation (prerequisites not met)"
311 | }
312 |
313 | Write-Step "Step 9: Running Diagnostic Test"
314 |
315 | if ($kicad -and $script:Results.ProjectBuilt) {
316 | Write-Info "Testing server startup..."
317 |
318 | $env:PYTHONPATH = $kicad.PythonLib
319 | $distPath = Join-Path $ProjectRoot "dist\index.js"
320 |
321 | # Start the server process
322 | $process = Start-Process -FilePath "node" `
323 | -ArgumentList $distPath `
324 | -NoNewWindow `
325 | -PassThru `
326 | -RedirectStandardError (Join-Path $env:TEMP "kicad-mcp-test-error.txt") `
327 | -RedirectStandardOutput (Join-Path $env:TEMP "kicad-mcp-test-output.txt")
328 |
329 | # Wait a moment for startup
330 | Start-Sleep -Seconds 2
331 |
332 | if (-not $process.HasExited) {
333 | Write-Success "Server started successfully (PID: $($process.Id))"
334 | Write-Info "Stopping test server..."
335 | Stop-Process -Id $process.Id -Force
336 | } else {
337 | Write-Error-Custom "Server exited immediately (exit code: $($process.ExitCode))"
338 |
339 | $errorLog = Join-Path $env:TEMP "kicad-mcp-test-error.txt"
340 | if (Test-Path $errorLog) {
341 | $errorContent = Get-Content $errorLog -Raw
342 | if ($errorContent) {
343 | Write-Warning-Custom "Error output:"
344 | Write-Host $errorContent -ForegroundColor Red
345 | }
346 | }
347 |
348 | $script:Results.Errors += "Server startup test failed"
349 | }
350 | } else {
351 | Write-Warning-Custom "Skipping diagnostic test (prerequisites not met)"
352 | }
353 |
354 | # Final Report
355 | Write-Step "Setup Summary"
356 |
357 | Write-Host "`nComponent Status:" -ForegroundColor Cyan
358 | Write-Host " KiCAD Installation: $(if ($script:Results.KiCADFound) { '[OK] Found' } else { '[ERROR] Not Found' })" -ForegroundColor $(if ($script:Results.KiCADFound) { 'Green' } else { 'Red' })
359 | if ($script:Results.KiCADVersion) {
360 | Write-Host " Version: $($script:Results.KiCADVersion)" -ForegroundColor Gray
361 | }
362 | Write-Host " pcbnew Module: $(if ($script:Results.PcbnewImport) { '[OK] Working' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.PcbnewImport) { 'Green' } else { 'Red' })
363 | Write-Host " Node.js: $(if ($script:Results.NodeFound) { '[OK] Found' } else { '[ERROR] Not Found' })" -ForegroundColor $(if ($script:Results.NodeFound) { 'Green' } else { 'Red' })
364 | if ($script:Results.NodeVersion) {
365 | Write-Host " Version: $($script:Results.NodeVersion)" -ForegroundColor Gray
366 | }
367 | Write-Host " Python Dependencies: $(if ($script:Results.DependenciesInstalled) { '[OK] Installed' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.DependenciesInstalled) { 'Green' } else { 'Red' })
368 | Write-Host " Project Build: $(if ($script:Results.ProjectBuilt) { '[OK] Success' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.ProjectBuilt) { 'Green' } else { 'Red' })
369 | Write-Host " Configuration: $(if ($script:Results.ConfigGenerated) { '[OK] Generated' } else { '[ERROR] Not Generated' })" -ForegroundColor $(if ($script:Results.ConfigGenerated) { 'Green' } else { 'Red' })
370 |
371 | if ($script:Results.Errors.Count -gt 0) {
372 | Write-Host "`nErrors Encountered:" -ForegroundColor Red
373 | foreach ($error in $script:Results.Errors) {
374 | Write-Host " • $error" -ForegroundColor Red
375 | }
376 | }
377 |
378 | # Check for log file
379 | $logPath = "$env:USERPROFILE\.kicad-mcp\logs\kicad_interface.log"
380 | if (Test-Path $logPath) {
381 | Write-Host "`nLog file location:" -ForegroundColor Cyan
382 | Write-Host " $logPath" -ForegroundColor Gray
383 | }
384 |
385 | # Success criteria
386 | $isSuccess = $script:Results.KiCADFound -and
387 | $script:Results.PcbnewImport -and
388 | $script:Results.NodeFound -and
389 | $script:Results.ProjectBuilt
390 |
391 | if ($isSuccess) {
392 | Write-Host "`n============================================================" -ForegroundColor Green
393 | Write-Host " [OK] Setup completed successfully!" -ForegroundColor Green
394 | Write-Host "" -ForegroundColor Green
395 | Write-Host " Next steps:" -ForegroundColor Green
396 | Write-Host " 1. Copy the generated config to your MCP client" -ForegroundColor Green
397 | Write-Host " 2. Restart your MCP client (Claude Desktop/Cline)" -ForegroundColor Green
398 | Write-Host " 3. Try: 'Create a new KiCAD project'" -ForegroundColor Green
399 | Write-Host "============================================================" -ForegroundColor Green
400 | } else {
401 | Write-Host "`n============================================================" -ForegroundColor Red
402 | Write-Host " [ERROR] Setup incomplete - issues detected" -ForegroundColor Red
403 | Write-Host "" -ForegroundColor Red
404 | Write-Host " Please resolve the errors above and run again" -ForegroundColor Red
405 | Write-Host "" -ForegroundColor Red
406 | Write-Host " For help:" -ForegroundColor Red
407 | Write-Host " https://github.com/mixelpixx/KiCAD-MCP-Server/issues" -ForegroundColor Red
408 | Write-Host "============================================================" -ForegroundColor Red
409 | exit 1
410 | }
411 |
```
--------------------------------------------------------------------------------
/python/commands/library.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Library management for KiCAD footprints
3 |
4 | Handles parsing fp-lib-table files, discovering footprints,
5 | and providing search functionality for component placement.
6 | """
7 |
8 | import os
9 | import re
10 | import logging
11 | from pathlib import Path
12 | from typing import Dict, List, Optional, Tuple
13 | import glob
14 |
15 | logger = logging.getLogger('kicad_interface')
16 |
17 |
18 | class LibraryManager:
19 | """
20 | Manages KiCAD footprint libraries
21 |
22 | Parses fp-lib-table files (both global and project-specific),
23 | indexes available footprints, and provides search functionality.
24 | """
25 |
26 | def __init__(self, project_path: Optional[Path] = None):
27 | """
28 | Initialize library manager
29 |
30 | Args:
31 | project_path: Optional path to project directory for project-specific libraries
32 | """
33 | self.project_path = project_path
34 | self.libraries: Dict[str, str] = {} # nickname -> path mapping
35 | self.footprint_cache: Dict[str, List[str]] = {} # library -> [footprint names]
36 | self._load_libraries()
37 |
38 | def _load_libraries(self):
39 | """Load libraries from fp-lib-table files"""
40 | # Load global libraries
41 | global_table = self._get_global_fp_lib_table()
42 | if global_table and global_table.exists():
43 | logger.info(f"Loading global fp-lib-table from: {global_table}")
44 | self._parse_fp_lib_table(global_table)
45 | else:
46 | logger.warning(f"Global fp-lib-table not found at: {global_table}")
47 |
48 | # Load project-specific libraries if project path provided
49 | if self.project_path:
50 | project_table = self.project_path / "fp-lib-table"
51 | if project_table.exists():
52 | logger.info(f"Loading project fp-lib-table from: {project_table}")
53 | self._parse_fp_lib_table(project_table)
54 |
55 | logger.info(f"Loaded {len(self.libraries)} footprint libraries")
56 |
57 | def _get_global_fp_lib_table(self) -> Optional[Path]:
58 | """Get path to global fp-lib-table file"""
59 | # Try different possible locations
60 | kicad_config_paths = [
61 | Path.home() / ".config" / "kicad" / "9.0" / "fp-lib-table",
62 | Path.home() / ".config" / "kicad" / "8.0" / "fp-lib-table",
63 | Path.home() / ".config" / "kicad" / "fp-lib-table",
64 | # Windows paths
65 | Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "fp-lib-table",
66 | Path.home() / "AppData" / "Roaming" / "kicad" / "8.0" / "fp-lib-table",
67 | # macOS paths
68 | Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "fp-lib-table",
69 | Path.home() / "Library" / "Preferences" / "kicad" / "8.0" / "fp-lib-table",
70 | ]
71 |
72 | for path in kicad_config_paths:
73 | if path.exists():
74 | return path
75 |
76 | return None
77 |
78 | def _parse_fp_lib_table(self, table_path: Path):
79 | """
80 | Parse fp-lib-table file
81 |
82 | Format is S-expression (Lisp-like):
83 | (fp_lib_table
84 | (lib (name "Library_Name")(type KiCad)(uri "${KICAD9_FOOTPRINT_DIR}/Library.pretty")(options "")(descr "Description"))
85 | )
86 | """
87 | try:
88 | with open(table_path, 'r') as f:
89 | content = f.read()
90 |
91 | # Simple regex-based parser for lib entries
92 | # Pattern: (lib (name "NAME")(type TYPE)(uri "URI")...)
93 | lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^")\s]+)"?'
94 |
95 | for match in re.finditer(lib_pattern, content, re.IGNORECASE):
96 | nickname = match.group(1)
97 | uri = match.group(2)
98 |
99 | # Resolve environment variables in URI
100 | resolved_uri = self._resolve_uri(uri)
101 |
102 | if resolved_uri:
103 | self.libraries[nickname] = resolved_uri
104 | logger.debug(f" Found library: {nickname} -> {resolved_uri}")
105 | else:
106 | logger.warning(f" Could not resolve URI for library {nickname}: {uri}")
107 |
108 | except Exception as e:
109 | logger.error(f"Error parsing fp-lib-table at {table_path}: {e}")
110 |
111 | def _resolve_uri(self, uri: str) -> Optional[str]:
112 | """
113 | Resolve environment variables and paths in library URI
114 |
115 | Handles:
116 | - ${KICAD9_FOOTPRINT_DIR} -> /usr/share/kicad/footprints
117 | - ${KICAD8_FOOTPRINT_DIR} -> /usr/share/kicad/footprints
118 | - ${KIPRJMOD} -> project directory
119 | - Relative paths
120 | - Absolute paths
121 | """
122 | # Replace environment variables
123 | resolved = uri
124 |
125 | # Common KiCAD environment variables
126 | env_vars = {
127 | 'KICAD9_FOOTPRINT_DIR': self._find_kicad_footprint_dir(),
128 | 'KICAD8_FOOTPRINT_DIR': self._find_kicad_footprint_dir(),
129 | 'KICAD_FOOTPRINT_DIR': self._find_kicad_footprint_dir(),
130 | 'KISYSMOD': self._find_kicad_footprint_dir(),
131 | }
132 |
133 | # Project directory
134 | if self.project_path:
135 | env_vars['KIPRJMOD'] = str(self.project_path)
136 |
137 | # Replace environment variables
138 | for var, value in env_vars.items():
139 | if value:
140 | resolved = resolved.replace(f'${{{var}}}', value)
141 | resolved = resolved.replace(f'${var}', value)
142 |
143 | # Expand ~ to home directory
144 | resolved = os.path.expanduser(resolved)
145 |
146 | # Convert to absolute path
147 | path = Path(resolved)
148 |
149 | # Check if path exists
150 | if path.exists():
151 | return str(path)
152 | else:
153 | logger.debug(f" Path does not exist: {path}")
154 | return None
155 |
156 | def _find_kicad_footprint_dir(self) -> Optional[str]:
157 | """Find KiCAD footprint directory"""
158 | # Try common locations
159 | possible_paths = [
160 | "/usr/share/kicad/footprints",
161 | "/usr/local/share/kicad/footprints",
162 | "C:/Program Files/KiCad/9.0/share/kicad/footprints",
163 | "C:/Program Files/KiCad/8.0/share/kicad/footprints",
164 | "/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints",
165 | ]
166 |
167 | # Also check environment variable
168 | if 'KICAD9_FOOTPRINT_DIR' in os.environ:
169 | possible_paths.insert(0, os.environ['KICAD9_FOOTPRINT_DIR'])
170 | if 'KICAD8_FOOTPRINT_DIR' in os.environ:
171 | possible_paths.insert(0, os.environ['KICAD8_FOOTPRINT_DIR'])
172 |
173 | for path in possible_paths:
174 | if os.path.isdir(path):
175 | return path
176 |
177 | return None
178 |
179 | def list_libraries(self) -> List[str]:
180 | """Get list of available library nicknames"""
181 | return list(self.libraries.keys())
182 |
183 | def get_library_path(self, nickname: str) -> Optional[str]:
184 | """Get filesystem path for a library nickname"""
185 | return self.libraries.get(nickname)
186 |
187 | def list_footprints(self, library_nickname: str) -> List[str]:
188 | """
189 | List all footprints in a library
190 |
191 | Args:
192 | library_nickname: Library name (e.g., "Resistor_SMD")
193 |
194 | Returns:
195 | List of footprint names (without .kicad_mod extension)
196 | """
197 | # Check cache first
198 | if library_nickname in self.footprint_cache:
199 | return self.footprint_cache[library_nickname]
200 |
201 | library_path = self.libraries.get(library_nickname)
202 | if not library_path:
203 | logger.warning(f"Library not found: {library_nickname}")
204 | return []
205 |
206 | try:
207 | footprints = []
208 | lib_dir = Path(library_path)
209 |
210 | # List all .kicad_mod files
211 | for fp_file in lib_dir.glob("*.kicad_mod"):
212 | # Remove .kicad_mod extension
213 | footprint_name = fp_file.stem
214 | footprints.append(footprint_name)
215 |
216 | # Cache the results
217 | self.footprint_cache[library_nickname] = footprints
218 | logger.debug(f"Found {len(footprints)} footprints in {library_nickname}")
219 |
220 | return footprints
221 |
222 | except Exception as e:
223 | logger.error(f"Error listing footprints in {library_nickname}: {e}")
224 | return []
225 |
226 | def find_footprint(self, footprint_spec: str) -> Optional[Tuple[str, str]]:
227 | """
228 | Find a footprint by specification
229 |
230 | Supports multiple formats:
231 | - "Library:Footprint" (e.g., "Resistor_SMD:R_0603_1608Metric")
232 | - "Footprint" (searches all libraries)
233 |
234 | Args:
235 | footprint_spec: Footprint specification
236 |
237 | Returns:
238 | Tuple of (library_path, footprint_name) or None if not found
239 | """
240 | # Parse specification
241 | if ":" in footprint_spec:
242 | # Format: Library:Footprint
243 | library_nickname, footprint_name = footprint_spec.split(":", 1)
244 | library_path = self.libraries.get(library_nickname)
245 |
246 | if not library_path:
247 | logger.warning(f"Library not found: {library_nickname}")
248 | return None
249 |
250 | # Check if footprint exists
251 | fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
252 | if fp_file.exists():
253 | return (library_path, footprint_name)
254 | else:
255 | logger.warning(f"Footprint not found: {footprint_spec}")
256 | return None
257 | else:
258 | # Format: Footprint (search all libraries)
259 | footprint_name = footprint_spec
260 |
261 | # Search in all libraries
262 | for library_nickname, library_path in self.libraries.items():
263 | fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
264 | if fp_file.exists():
265 | logger.info(f"Found footprint {footprint_name} in library {library_nickname}")
266 | return (library_path, footprint_name)
267 |
268 | logger.warning(f"Footprint not found in any library: {footprint_name}")
269 | return None
270 |
271 | def search_footprints(self, pattern: str, limit: int = 20) -> List[Dict[str, str]]:
272 | """
273 | Search for footprints matching a pattern
274 |
275 | Args:
276 | pattern: Search pattern (supports wildcards *, case-insensitive)
277 | limit: Maximum number of results to return
278 |
279 | Returns:
280 | List of dicts with 'library', 'footprint', and 'full_name' keys
281 | """
282 | results = []
283 | pattern_lower = pattern.lower()
284 |
285 | # Convert wildcards to regex
286 | regex_pattern = pattern_lower.replace("*", ".*")
287 | regex = re.compile(regex_pattern)
288 |
289 | for library_nickname in self.libraries.keys():
290 | footprints = self.list_footprints(library_nickname)
291 |
292 | for footprint in footprints:
293 | if regex.search(footprint.lower()):
294 | results.append({
295 | 'library': library_nickname,
296 | 'footprint': footprint,
297 | 'full_name': f"{library_nickname}:{footprint}"
298 | })
299 |
300 | if len(results) >= limit:
301 | return results
302 |
303 | return results
304 |
305 | def get_footprint_info(self, library_nickname: str, footprint_name: str) -> Optional[Dict[str, str]]:
306 | """
307 | Get information about a specific footprint
308 |
309 | Args:
310 | library_nickname: Library name
311 | footprint_name: Footprint name
312 |
313 | Returns:
314 | Dict with footprint information or None if not found
315 | """
316 | library_path = self.libraries.get(library_nickname)
317 | if not library_path:
318 | return None
319 |
320 | fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
321 | if not fp_file.exists():
322 | return None
323 |
324 | return {
325 | 'library': library_nickname,
326 | 'footprint': footprint_name,
327 | 'full_name': f"{library_nickname}:{footprint_name}",
328 | 'path': str(fp_file),
329 | 'library_path': library_path
330 | }
331 |
332 |
333 | class LibraryCommands:
334 | """Command handlers for library operations"""
335 |
336 | def __init__(self, library_manager: Optional[LibraryManager] = None):
337 | """Initialize with optional library manager"""
338 | self.library_manager = library_manager or LibraryManager()
339 |
340 | def list_libraries(self, params: Dict) -> Dict:
341 | """List all available footprint libraries"""
342 | try:
343 | libraries = self.library_manager.list_libraries()
344 | return {
345 | "success": True,
346 | "libraries": libraries,
347 | "count": len(libraries)
348 | }
349 | except Exception as e:
350 | logger.error(f"Error listing libraries: {e}")
351 | return {
352 | "success": False,
353 | "message": "Failed to list libraries",
354 | "errorDetails": str(e)
355 | }
356 |
357 | def search_footprints(self, params: Dict) -> Dict:
358 | """Search for footprints by pattern"""
359 | try:
360 | pattern = params.get("pattern", "*")
361 | limit = params.get("limit", 20)
362 |
363 | results = self.library_manager.search_footprints(pattern, limit)
364 |
365 | return {
366 | "success": True,
367 | "footprints": results,
368 | "count": len(results),
369 | "pattern": pattern
370 | }
371 | except Exception as e:
372 | logger.error(f"Error searching footprints: {e}")
373 | return {
374 | "success": False,
375 | "message": "Failed to search footprints",
376 | "errorDetails": str(e)
377 | }
378 |
379 | def list_library_footprints(self, params: Dict) -> Dict:
380 | """List all footprints in a specific library"""
381 | try:
382 | library = params.get("library")
383 | if not library:
384 | return {
385 | "success": False,
386 | "message": "Missing library parameter"
387 | }
388 |
389 | footprints = self.library_manager.list_footprints(library)
390 |
391 | return {
392 | "success": True,
393 | "library": library,
394 | "footprints": footprints,
395 | "count": len(footprints)
396 | }
397 | except Exception as e:
398 | logger.error(f"Error listing library footprints: {e}")
399 | return {
400 | "success": False,
401 | "message": "Failed to list library footprints",
402 | "errorDetails": str(e)
403 | }
404 |
405 | def get_footprint_info(self, params: Dict) -> Dict:
406 | """Get information about a specific footprint"""
407 | try:
408 | footprint_spec = params.get("footprint")
409 | if not footprint_spec:
410 | return {
411 | "success": False,
412 | "message": "Missing footprint parameter"
413 | }
414 |
415 | # Try to find the footprint
416 | result = self.library_manager.find_footprint(footprint_spec)
417 |
418 | if result:
419 | library_path, footprint_name = result
420 | # Extract library nickname from path
421 | library_nickname = None
422 | for nick, path in self.library_manager.libraries.items():
423 | if path == library_path:
424 | library_nickname = nick
425 | break
426 |
427 | info = {
428 | "library": library_nickname,
429 | "footprint": footprint_name,
430 | "full_name": f"{library_nickname}:{footprint_name}",
431 | "library_path": library_path
432 | }
433 |
434 | return {
435 | "success": True,
436 | "footprint_info": info
437 | }
438 | else:
439 | return {
440 | "success": False,
441 | "message": f"Footprint not found: {footprint_spec}"
442 | }
443 |
444 | except Exception as e:
445 | logger.error(f"Error getting footprint info: {e}")
446 | return {
447 | "success": False,
448 | "message": "Failed to get footprint info",
449 | "errorDetails": str(e)
450 | }
451 |
```
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * KiCAD MCP Server implementation
3 | */
4 |
5 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
6 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7 | import express from 'express';
8 | import { spawn, exec, ChildProcess } from 'child_process';
9 | import { existsSync } from 'fs';
10 | import { join, dirname } from 'path';
11 | import { logger } from './logger.js';
12 |
13 | // Import tool registration functions
14 | import { registerProjectTools } from './tools/project.js';
15 | import { registerBoardTools } from './tools/board.js';
16 | import { registerComponentTools } from './tools/component.js';
17 | import { registerRoutingTools } from './tools/routing.js';
18 | import { registerDesignRuleTools } from './tools/design-rules.js';
19 | import { registerExportTools } from './tools/export.js';
20 | import { registerSchematicTools } from './tools/schematic.js';
21 | import { registerLibraryTools } from './tools/library.js';
22 | import { registerUITools } from './tools/ui.js';
23 |
24 | // Import resource registration functions
25 | import { registerProjectResources } from './resources/project.js';
26 | import { registerBoardResources } from './resources/board.js';
27 | import { registerComponentResources } from './resources/component.js';
28 | import { registerLibraryResources } from './resources/library.js';
29 |
30 | // Import prompt registration functions
31 | import { registerComponentPrompts } from './prompts/component.js';
32 | import { registerRoutingPrompts } from './prompts/routing.js';
33 | import { registerDesignPrompts } from './prompts/design.js';
34 |
35 | /**
36 | * Find the Python executable to use
37 | * Prioritizes virtual environment if available, falls back to system Python
38 | */
39 | function findPythonExecutable(scriptPath: string): string {
40 | const isWindows = process.platform === 'win32';
41 |
42 | // Get the project root (parent of the python/ directory)
43 | const projectRoot = dirname(dirname(scriptPath));
44 |
45 | // Check for virtual environment
46 | const venvPaths = [
47 | join(projectRoot, 'venv', isWindows ? 'Scripts' : 'bin', isWindows ? 'python.exe' : 'python'),
48 | join(projectRoot, '.venv', isWindows ? 'Scripts' : 'bin', isWindows ? 'python.exe' : 'python'),
49 | ];
50 |
51 | for (const venvPath of venvPaths) {
52 | if (existsSync(venvPath)) {
53 | logger.info(`Found virtual environment Python at: ${venvPath}`);
54 | return venvPath;
55 | }
56 | }
57 |
58 | // Fall back to system Python or environment-specified Python
59 | if (isWindows && process.env.KICAD_PYTHON) {
60 | // Allow override via KICAD_PYTHON environment variable
61 | return process.env.KICAD_PYTHON;
62 | } else if (isWindows && process.env.PYTHONPATH?.includes('KiCad')) {
63 | // Windows: Try KiCAD's bundled Python
64 | const kicadPython = 'C:\\Program Files\\KiCad\\9.0\\bin\\python.exe';
65 | if (existsSync(kicadPython)) {
66 | return kicadPython;
67 | }
68 | }
69 |
70 | // Default to system Python
71 | logger.info('Using system Python (no venv found)');
72 | return isWindows ? 'python.exe' : 'python3';
73 | }
74 |
75 | /**
76 | * KiCAD MCP Server class
77 | */
78 | export class KiCADMcpServer {
79 | private server: McpServer;
80 | private pythonProcess: ChildProcess | null = null;
81 | private kicadScriptPath: string;
82 | private stdioTransport!: StdioServerTransport;
83 | private requestQueue: Array<{ request: any, resolve: Function, reject: Function }> = [];
84 | private processingRequest = false;
85 | private responseBuffer: string = '';
86 | private currentRequestHandler: { resolve: Function, reject: Function, timeoutHandle: NodeJS.Timeout } | null = null;
87 |
88 | /**
89 | * Constructor for the KiCAD MCP Server
90 | * @param kicadScriptPath Path to the Python KiCAD interface script
91 | * @param logLevel Log level for the server
92 | */
93 | constructor(
94 | kicadScriptPath: string,
95 | logLevel: 'error' | 'warn' | 'info' | 'debug' = 'info'
96 | ) {
97 | // Set up the logger
98 | logger.setLogLevel(logLevel);
99 |
100 | // Check if KiCAD script exists
101 | this.kicadScriptPath = kicadScriptPath;
102 | if (!existsSync(this.kicadScriptPath)) {
103 | throw new Error(`KiCAD interface script not found: ${this.kicadScriptPath}`);
104 | }
105 |
106 | // Initialize the MCP server
107 | this.server = new McpServer({
108 | name: 'kicad-mcp-server',
109 | version: '1.0.0',
110 | description: 'MCP server for KiCAD PCB design operations'
111 | });
112 |
113 | // Initialize STDIO transport
114 | this.stdioTransport = new StdioServerTransport();
115 | logger.info('Using STDIO transport for local communication');
116 |
117 | // Register tools, resources, and prompts
118 | this.registerAll();
119 | }
120 |
121 | /**
122 | * Register all tools, resources, and prompts
123 | */
124 | private registerAll(): void {
125 | logger.info('Registering KiCAD tools, resources, and prompts...');
126 |
127 | // Register all tools
128 | registerProjectTools(this.server, this.callKicadScript.bind(this));
129 | registerBoardTools(this.server, this.callKicadScript.bind(this));
130 | registerComponentTools(this.server, this.callKicadScript.bind(this));
131 | registerRoutingTools(this.server, this.callKicadScript.bind(this));
132 | registerDesignRuleTools(this.server, this.callKicadScript.bind(this));
133 | registerExportTools(this.server, this.callKicadScript.bind(this));
134 | registerSchematicTools(this.server, this.callKicadScript.bind(this));
135 | registerLibraryTools(this.server, this.callKicadScript.bind(this));
136 | registerUITools(this.server, this.callKicadScript.bind(this));
137 |
138 | // Register all resources
139 | registerProjectResources(this.server, this.callKicadScript.bind(this));
140 | registerBoardResources(this.server, this.callKicadScript.bind(this));
141 | registerComponentResources(this.server, this.callKicadScript.bind(this));
142 | registerLibraryResources(this.server, this.callKicadScript.bind(this));
143 |
144 | // Register all prompts
145 | registerComponentPrompts(this.server);
146 | registerRoutingPrompts(this.server);
147 | registerDesignPrompts(this.server);
148 |
149 | logger.info('All KiCAD tools, resources, and prompts registered');
150 | }
151 |
152 | /**
153 | * Validate prerequisites before starting the server
154 | */
155 | private async validatePrerequisites(pythonExe: string): Promise<boolean> {
156 | const isWindows = process.platform === 'win32';
157 | const errors: string[] = [];
158 |
159 | // Check if Python executable exists
160 | if (!existsSync(pythonExe)) {
161 | errors.push(`Python executable not found: ${pythonExe}`);
162 |
163 | if (isWindows) {
164 | errors.push('Windows: Install KiCAD 9.0+ from https://www.kicad.org/download/windows/');
165 | errors.push('Or run: .\\setup-windows.ps1 for automatic configuration');
166 | }
167 | }
168 |
169 | // Check if kicad_interface.py exists
170 | if (!existsSync(this.kicadScriptPath)) {
171 | errors.push(`KiCAD interface script not found: ${this.kicadScriptPath}`);
172 | }
173 |
174 | // Check if dist/index.js exists (if running from compiled code)
175 | const distPath = join(dirname(dirname(this.kicadScriptPath)), 'dist', 'index.js');
176 | if (!existsSync(distPath)) {
177 | errors.push('Project not built. Run: npm run build');
178 | }
179 |
180 | // Try to test pcbnew import (quick validation)
181 | if (existsSync(pythonExe) && existsSync(this.kicadScriptPath)) {
182 | logger.info('Validating pcbnew module access...');
183 |
184 | const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`;
185 |
186 | try {
187 | const { stdout, stderr } = await new Promise<{stdout: string, stderr: string}>((resolve, reject) => {
188 | exec(testCommand, {
189 | timeout: 5000,
190 | env: { ...process.env }
191 | }, (error: any, stdout: string, stderr: string) => {
192 | if (error) {
193 | reject(error);
194 | } else {
195 | resolve({ stdout, stderr });
196 | }
197 | });
198 | });
199 |
200 | if (!stdout.includes('OK')) {
201 | errors.push('pcbnew module import test failed');
202 | errors.push(`Output: ${stdout}`);
203 | errors.push(`Errors: ${stderr}`);
204 |
205 | if (isWindows) {
206 | errors.push('');
207 | errors.push('Windows troubleshooting:');
208 | errors.push('1. Set PYTHONPATH=C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages');
209 | errors.push('2. Test: "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe" -c "import pcbnew"');
210 | errors.push('3. Run: .\\setup-windows.ps1 for automatic fix');
211 | errors.push('4. See: docs/WINDOWS_TROUBLESHOOTING.md');
212 | }
213 | } else {
214 | logger.info('✓ pcbnew module validated successfully');
215 | }
216 | } catch (error: any) {
217 | errors.push(`pcbnew validation failed: ${error.message}`);
218 |
219 | if (isWindows) {
220 | errors.push('');
221 | errors.push('This usually means:');
222 | errors.push('- KiCAD is not installed');
223 | errors.push('- PYTHONPATH is incorrect');
224 | errors.push('- Python cannot find pcbnew module');
225 | errors.push('');
226 | errors.push('Quick fix: Run .\\setup-windows.ps1');
227 | }
228 | }
229 | }
230 |
231 | // Log all errors
232 | if (errors.length > 0) {
233 | logger.error('='.repeat(70));
234 | logger.error('STARTUP VALIDATION FAILED');
235 | logger.error('='.repeat(70));
236 | errors.forEach(err => logger.error(err));
237 | logger.error('='.repeat(70));
238 |
239 | // Also write to stderr for Claude Desktop to capture
240 | process.stderr.write('\n' + '='.repeat(70) + '\n');
241 | process.stderr.write('KiCAD MCP Server - Startup Validation Failed\n');
242 | process.stderr.write('='.repeat(70) + '\n');
243 | errors.forEach(err => process.stderr.write(err + '\n'));
244 | process.stderr.write('='.repeat(70) + '\n\n');
245 |
246 | return false;
247 | }
248 |
249 | return true;
250 | }
251 |
252 | /**
253 | * Start the MCP server and the Python KiCAD interface
254 | */
255 | async start(): Promise<void> {
256 | try {
257 | logger.info('Starting KiCAD MCP server...');
258 |
259 | // Start the Python process for KiCAD scripting
260 | logger.info(`Starting Python process with script: ${this.kicadScriptPath}`);
261 | const pythonExe = findPythonExecutable(this.kicadScriptPath);
262 |
263 | logger.info(`Using Python executable: ${pythonExe}`);
264 |
265 | // Validate prerequisites
266 | const isValid = await this.validatePrerequisites(pythonExe);
267 | if (!isValid) {
268 | throw new Error('Prerequisites validation failed. See logs above for details.');
269 | }
270 | this.pythonProcess = spawn(pythonExe, [this.kicadScriptPath], {
271 | stdio: ['pipe', 'pipe', 'pipe'],
272 | env: {
273 | ...process.env,
274 | PYTHONPATH: process.env.PYTHONPATH || 'C:/Program Files/KiCad/9.0/lib/python3/dist-packages'
275 | }
276 | });
277 |
278 | // Listen for process exit
279 | this.pythonProcess.on('exit', (code, signal) => {
280 | logger.warn(`Python process exited with code ${code} and signal ${signal}`);
281 | this.pythonProcess = null;
282 | });
283 |
284 | // Listen for process errors
285 | this.pythonProcess.on('error', (err) => {
286 | logger.error(`Python process error: ${err.message}`);
287 | });
288 |
289 | // Set up error logging for stderr
290 | if (this.pythonProcess.stderr) {
291 | this.pythonProcess.stderr.on('data', (data: Buffer) => {
292 | logger.error(`Python stderr: ${data.toString()}`);
293 | });
294 | }
295 |
296 | // Set up persistent stdout handler (instead of adding/removing per request)
297 | if (this.pythonProcess.stdout) {
298 | this.pythonProcess.stdout.on('data', (data: Buffer) => {
299 | this.handlePythonResponse(data);
300 | });
301 | }
302 |
303 | // Connect server to STDIO transport
304 | logger.info('Connecting MCP server to STDIO transport...');
305 | try {
306 | await this.server.connect(this.stdioTransport);
307 | logger.info('Successfully connected to STDIO transport');
308 | } catch (error) {
309 | logger.error(`Failed to connect to STDIO transport: ${error}`);
310 | throw error;
311 | }
312 |
313 | // Write a ready message to stderr (for debugging)
314 | process.stderr.write('KiCAD MCP SERVER READY\n');
315 |
316 | logger.info('KiCAD MCP server started and ready');
317 | } catch (error) {
318 | logger.error(`Failed to start KiCAD MCP server: ${error}`);
319 | throw error;
320 | }
321 | }
322 |
323 | /**
324 | * Stop the MCP server and clean up resources
325 | */
326 | async stop(): Promise<void> {
327 | logger.info('Stopping KiCAD MCP server...');
328 |
329 | // Kill the Python process if it's running
330 | if (this.pythonProcess) {
331 | this.pythonProcess.kill();
332 | this.pythonProcess = null;
333 | }
334 |
335 | logger.info('KiCAD MCP server stopped');
336 | }
337 |
338 | /**
339 | * Call the KiCAD scripting interface to execute commands
340 | *
341 | * @param command The command to execute
342 | * @param params The parameters for the command
343 | * @returns The result of the command execution
344 | */
345 | private async callKicadScript(command: string, params: any): Promise<any> {
346 | return new Promise((resolve, reject) => {
347 | // Check if Python process is running
348 | if (!this.pythonProcess) {
349 | logger.error('Python process is not running');
350 | reject(new Error("Python process for KiCAD scripting is not running"));
351 | return;
352 | }
353 |
354 | // Determine timeout based on command type
355 | // DRC and export operations need longer timeouts for large boards
356 | let commandTimeout = 30000; // Default 30 seconds
357 | const longRunningCommands = ['run_drc', 'export_gerber', 'export_pdf', 'export_3d'];
358 | if (longRunningCommands.includes(command)) {
359 | commandTimeout = 600000; // 10 minutes for long operations
360 | logger.info(`Using extended timeout (${commandTimeout/1000}s) for command: ${command}`);
361 | }
362 |
363 | // Add request to queue with timeout info
364 | this.requestQueue.push({
365 | request: { command, params, timeout: commandTimeout },
366 | resolve,
367 | reject
368 | });
369 |
370 | // Process the queue if not already processing
371 | if (!this.processingRequest) {
372 | this.processNextRequest();
373 | }
374 | });
375 | }
376 |
377 | /**
378 | * Handle incoming data from Python process stdout
379 | * This is a persistent handler that processes all responses
380 | */
381 | private handlePythonResponse(data: Buffer): void {
382 | const chunk = data.toString();
383 | logger.debug(`Received data chunk: ${chunk.length} bytes`);
384 | this.responseBuffer += chunk;
385 |
386 | // Try to parse complete JSON responses (may have multiple or partial)
387 | this.tryParseResponse();
388 | }
389 |
390 | /**
391 | * Try to parse a complete JSON response from the buffer
392 | */
393 | private tryParseResponse(): void {
394 | if (!this.currentRequestHandler) {
395 | // No pending request, clear buffer if it has data (shouldn't happen)
396 | if (this.responseBuffer.trim()) {
397 | logger.warn(`Received data with no pending request: ${this.responseBuffer.substring(0, 100)}...`);
398 | this.responseBuffer = '';
399 | }
400 | return;
401 | }
402 |
403 | try {
404 | // Try to parse the response as JSON
405 | const result = JSON.parse(this.responseBuffer);
406 |
407 | // If we get here, we have a valid JSON response
408 | logger.debug(`Completed KiCAD command with result: ${result.success ? 'success' : 'failure'}`);
409 |
410 | // Clear the timeout since we got a response
411 | if (this.currentRequestHandler.timeoutHandle) {
412 | clearTimeout(this.currentRequestHandler.timeoutHandle);
413 | }
414 |
415 | // Get the handler before clearing
416 | const handler = this.currentRequestHandler;
417 |
418 | // Clear state
419 | this.responseBuffer = '';
420 | this.currentRequestHandler = null;
421 | this.processingRequest = false;
422 |
423 | // Resolve the promise with the result
424 | handler.resolve(result);
425 |
426 | // Process next request if any
427 | setTimeout(() => this.processNextRequest(), 0);
428 |
429 | } catch (e) {
430 | // Not a complete JSON yet, keep collecting data
431 | // This is normal for large responses that come in chunks
432 | }
433 | }
434 |
435 | /**
436 | * Process the next request in the queue
437 | */
438 | private processNextRequest(): void {
439 | // If no more requests or already processing, return
440 | if (this.requestQueue.length === 0 || this.processingRequest) {
441 | return;
442 | }
443 |
444 | // Set processing flag
445 | this.processingRequest = true;
446 |
447 | // Get the next request
448 | const { request, resolve, reject } = this.requestQueue.shift()!;
449 |
450 | try {
451 | logger.debug(`Processing KiCAD command: ${request.command}`);
452 |
453 | // Format the command and parameters as JSON
454 | const requestStr = JSON.stringify(request);
455 |
456 | // Clear response buffer for new request
457 | this.responseBuffer = '';
458 |
459 | // Set a timeout (use command-specific timeout or default)
460 | const timeoutDuration = request.timeout || 30000;
461 | const timeoutHandle = setTimeout(() => {
462 | logger.error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`);
463 | logger.error(`Buffer contents: ${this.responseBuffer.substring(0, 200)}...`);
464 |
465 | // Clear state
466 | this.responseBuffer = '';
467 | this.currentRequestHandler = null;
468 | this.processingRequest = false;
469 |
470 | // Reject the promise
471 | reject(new Error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`));
472 |
473 | // Process next request
474 | setTimeout(() => this.processNextRequest(), 0);
475 | }, timeoutDuration);
476 |
477 | // Store the current request handler
478 | this.currentRequestHandler = { resolve, reject, timeoutHandle };
479 |
480 | // Write the request to the Python process
481 | logger.debug(`Sending request: ${requestStr}`);
482 | this.pythonProcess?.stdin?.write(requestStr + '\n');
483 | } catch (error) {
484 | logger.error(`Error processing request: ${error}`);
485 |
486 | // Reset processing flag
487 | this.processingRequest = false;
488 | this.currentRequestHandler = null;
489 |
490 | // Process next request
491 | setTimeout(() => this.processNextRequest(), 0);
492 |
493 | // Reject the promise
494 | reject(error);
495 | }
496 | }
497 | }
498 |
```
--------------------------------------------------------------------------------
/python/commands/board/outline.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Board outline command implementations for KiCAD interface
3 | """
4 |
5 | import pcbnew
6 | import logging
7 | import math
8 | from typing import Dict, Any, Optional
9 |
10 | logger = logging.getLogger('kicad_interface')
11 |
12 | class BoardOutlineCommands:
13 | """Handles board outline operations"""
14 |
15 | def __init__(self, board: Optional[pcbnew.BOARD] = None):
16 | """Initialize with optional board instance"""
17 | self.board = board
18 |
19 | def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
20 | """Add a board outline to the PCB"""
21 | try:
22 | if not self.board:
23 | return {
24 | "success": False,
25 | "message": "No board is loaded",
26 | "errorDetails": "Load or create a board first"
27 | }
28 |
29 | shape = params.get("shape", "rectangle")
30 | width = params.get("width")
31 | height = params.get("height")
32 | center_x = params.get("centerX", 0)
33 | center_y = params.get("centerY", 0)
34 | radius = params.get("radius")
35 | corner_radius = params.get("cornerRadius", 0)
36 | points = params.get("points", [])
37 | unit = params.get("unit", "mm")
38 |
39 | if shape not in ["rectangle", "circle", "polygon", "rounded_rectangle"]:
40 | return {
41 | "success": False,
42 | "message": "Invalid shape",
43 | "errorDetails": f"Shape '{shape}' not supported"
44 | }
45 |
46 | # Convert to internal units (nanometers)
47 | scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm
48 |
49 | # Create drawing for edge cuts
50 | edge_layer = self.board.GetLayerID("Edge.Cuts")
51 |
52 | if shape == "rectangle":
53 | if width is None or height is None:
54 | return {
55 | "success": False,
56 | "message": "Missing dimensions",
57 | "errorDetails": "Both width and height are required for rectangle"
58 | }
59 |
60 | width_nm = int(width * scale)
61 | height_nm = int(height * scale)
62 | center_x_nm = int(center_x * scale)
63 | center_y_nm = int(center_y * scale)
64 |
65 | # Create rectangle
66 | top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
67 | top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2)
68 | bottom_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm + height_nm // 2)
69 | bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
70 |
71 | # Add lines for rectangle
72 | self._add_edge_line(top_left, top_right, edge_layer)
73 | self._add_edge_line(top_right, bottom_right, edge_layer)
74 | self._add_edge_line(bottom_right, bottom_left, edge_layer)
75 | self._add_edge_line(bottom_left, top_left, edge_layer)
76 |
77 | elif shape == "rounded_rectangle":
78 | if width is None or height is None:
79 | return {
80 | "success": False,
81 | "message": "Missing dimensions",
82 | "errorDetails": "Both width and height are required for rounded rectangle"
83 | }
84 |
85 | width_nm = int(width * scale)
86 | height_nm = int(height * scale)
87 | center_x_nm = int(center_x * scale)
88 | center_y_nm = int(center_y * scale)
89 | corner_radius_nm = int(corner_radius * scale)
90 |
91 | # Create rounded rectangle
92 | self._add_rounded_rect(
93 | center_x_nm, center_y_nm,
94 | width_nm, height_nm,
95 | corner_radius_nm, edge_layer
96 | )
97 |
98 | elif shape == "circle":
99 | if radius is None:
100 | return {
101 | "success": False,
102 | "message": "Missing radius",
103 | "errorDetails": "Radius is required for circle"
104 | }
105 |
106 | center_x_nm = int(center_x * scale)
107 | center_y_nm = int(center_y * scale)
108 | radius_nm = int(radius * scale)
109 |
110 | # Create circle
111 | circle = pcbnew.PCB_SHAPE(self.board)
112 | circle.SetShape(pcbnew.SHAPE_T_CIRCLE)
113 | circle.SetCenter(pcbnew.VECTOR2I(center_x_nm, center_y_nm))
114 | circle.SetEnd(pcbnew.VECTOR2I(center_x_nm + radius_nm, center_y_nm))
115 | circle.SetLayer(edge_layer)
116 | circle.SetWidth(0) # Zero width for edge cuts
117 | self.board.Add(circle)
118 |
119 | elif shape == "polygon":
120 | if not points or len(points) < 3:
121 | return {
122 | "success": False,
123 | "message": "Missing points",
124 | "errorDetails": "At least 3 points are required for polygon"
125 | }
126 |
127 | # Convert points to nm
128 | polygon_points = []
129 | for point in points:
130 | x_nm = int(point["x"] * scale)
131 | y_nm = int(point["y"] * scale)
132 | polygon_points.append(pcbnew.VECTOR2I(x_nm, y_nm))
133 |
134 | # Add lines for polygon
135 | for i in range(len(polygon_points)):
136 | self._add_edge_line(
137 | polygon_points[i],
138 | polygon_points[(i + 1) % len(polygon_points)],
139 | edge_layer
140 | )
141 |
142 | return {
143 | "success": True,
144 | "message": f"Added board outline: {shape}",
145 | "outline": {
146 | "shape": shape,
147 | "width": width,
148 | "height": height,
149 | "center": {"x": center_x, "y": center_y, "unit": unit},
150 | "radius": radius,
151 | "cornerRadius": corner_radius,
152 | "points": points
153 | }
154 | }
155 |
156 | except Exception as e:
157 | logger.error(f"Error adding board outline: {str(e)}")
158 | return {
159 | "success": False,
160 | "message": "Failed to add board outline",
161 | "errorDetails": str(e)
162 | }
163 |
164 | def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
165 | """Add a mounting hole to the PCB"""
166 | try:
167 | if not self.board:
168 | return {
169 | "success": False,
170 | "message": "No board is loaded",
171 | "errorDetails": "Load or create a board first"
172 | }
173 |
174 | position = params.get("position")
175 | diameter = params.get("diameter")
176 | pad_diameter = params.get("padDiameter")
177 | plated = params.get("plated", False)
178 |
179 | if not position or not diameter:
180 | return {
181 | "success": False,
182 | "message": "Missing parameters",
183 | "errorDetails": "position and diameter are required"
184 | }
185 |
186 | # Convert to internal units (nanometers)
187 | scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
188 | x_nm = int(position["x"] * scale)
189 | y_nm = int(position["y"] * scale)
190 | diameter_nm = int(diameter * scale)
191 | pad_diameter_nm = int(pad_diameter * scale) if pad_diameter else diameter_nm + scale # 1mm larger by default
192 |
193 | # Create footprint for mounting hole
194 | module = pcbnew.FOOTPRINT(self.board)
195 | module.SetReference(f"MH")
196 | module.SetValue(f"MountingHole_{diameter}mm")
197 |
198 | # Create the pad for the hole
199 | pad = pcbnew.PAD(module)
200 | pad.SetNumber(1)
201 | pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
202 | pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
203 | pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
204 | pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
205 | pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
206 | module.Add(pad)
207 |
208 | # Position the mounting hole
209 | module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
210 |
211 | # Add to board
212 | self.board.Add(module)
213 |
214 | return {
215 | "success": True,
216 | "message": "Added mounting hole",
217 | "mountingHole": {
218 | "position": position,
219 | "diameter": diameter,
220 | "padDiameter": pad_diameter or diameter + 1,
221 | "plated": plated
222 | }
223 | }
224 |
225 | except Exception as e:
226 | logger.error(f"Error adding mounting hole: {str(e)}")
227 | return {
228 | "success": False,
229 | "message": "Failed to add mounting hole",
230 | "errorDetails": str(e)
231 | }
232 |
233 | def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
234 | """Add text annotation to the PCB"""
235 | try:
236 | if not self.board:
237 | return {
238 | "success": False,
239 | "message": "No board is loaded",
240 | "errorDetails": "Load or create a board first"
241 | }
242 |
243 | text = params.get("text")
244 | position = params.get("position")
245 | layer = params.get("layer", "F.SilkS")
246 | size = params.get("size", 1.0)
247 | thickness = params.get("thickness", 0.15)
248 | rotation = params.get("rotation", 0)
249 | mirror = params.get("mirror", False)
250 |
251 | if not text or not position:
252 | return {
253 | "success": False,
254 | "message": "Missing parameters",
255 | "errorDetails": "text and position are required"
256 | }
257 |
258 | # Convert to internal units (nanometers)
259 | scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
260 | x_nm = int(position["x"] * scale)
261 | y_nm = int(position["y"] * scale)
262 | size_nm = int(size * scale)
263 | thickness_nm = int(thickness * scale)
264 |
265 | # Get layer ID
266 | layer_id = self.board.GetLayerID(layer)
267 | if layer_id < 0:
268 | return {
269 | "success": False,
270 | "message": "Invalid layer",
271 | "errorDetails": f"Layer '{layer}' does not exist"
272 | }
273 |
274 | # Create text
275 | pcb_text = pcbnew.PCB_TEXT(self.board)
276 | pcb_text.SetText(text)
277 | pcb_text.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
278 | pcb_text.SetLayer(layer_id)
279 | pcb_text.SetTextSize(pcbnew.VECTOR2I(size_nm, size_nm))
280 | pcb_text.SetTextThickness(thickness_nm)
281 |
282 | # Set rotation angle - KiCAD 9.0 uses EDA_ANGLE
283 | try:
284 | # Try KiCAD 9.0+ API (EDA_ANGLE)
285 | angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
286 | pcb_text.SetTextAngle(angle)
287 | except (AttributeError, TypeError):
288 | # Fall back to older API (decidegrees as integer)
289 | pcb_text.SetTextAngle(int(rotation * 10))
290 |
291 | pcb_text.SetMirrored(mirror)
292 |
293 | # Add to board
294 | self.board.Add(pcb_text)
295 |
296 | return {
297 | "success": True,
298 | "message": "Added text annotation",
299 | "text": {
300 | "text": text,
301 | "position": position,
302 | "layer": layer,
303 | "size": size,
304 | "thickness": thickness,
305 | "rotation": rotation,
306 | "mirror": mirror
307 | }
308 | }
309 |
310 | except Exception as e:
311 | logger.error(f"Error adding text: {str(e)}")
312 | return {
313 | "success": False,
314 | "message": "Failed to add text",
315 | "errorDetails": str(e)
316 | }
317 |
318 | def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None:
319 | """Add a line to the edge cuts layer"""
320 | line = pcbnew.PCB_SHAPE(self.board)
321 | line.SetShape(pcbnew.SHAPE_T_SEGMENT)
322 | line.SetStart(start)
323 | line.SetEnd(end)
324 | line.SetLayer(layer)
325 | line.SetWidth(0) # Zero width for edge cuts
326 | self.board.Add(line)
327 |
328 | def _add_rounded_rect(self, center_x_nm: int, center_y_nm: int,
329 | width_nm: int, height_nm: int,
330 | radius_nm: int, layer: int) -> None:
331 | """Add a rounded rectangle to the edge cuts layer"""
332 | if radius_nm <= 0:
333 | # If no radius, create regular rectangle
334 | top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
335 | top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2)
336 | bottom_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm + height_nm // 2)
337 | bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
338 |
339 | self._add_edge_line(top_left, top_right, layer)
340 | self._add_edge_line(top_right, bottom_right, layer)
341 | self._add_edge_line(bottom_right, bottom_left, layer)
342 | self._add_edge_line(bottom_left, top_left, layer)
343 | return
344 |
345 | # Calculate corner centers
346 | half_width = width_nm // 2
347 | half_height = height_nm // 2
348 |
349 | # Ensure radius is not larger than half the smallest dimension
350 | max_radius = min(half_width, half_height)
351 | if radius_nm > max_radius:
352 | radius_nm = max_radius
353 |
354 | # Calculate corner centers
355 | top_left_center = pcbnew.VECTOR2I(
356 | center_x_nm - half_width + radius_nm,
357 | center_y_nm - half_height + radius_nm
358 | )
359 | top_right_center = pcbnew.VECTOR2I(
360 | center_x_nm + half_width - radius_nm,
361 | center_y_nm - half_height + radius_nm
362 | )
363 | bottom_right_center = pcbnew.VECTOR2I(
364 | center_x_nm + half_width - radius_nm,
365 | center_y_nm + half_height - radius_nm
366 | )
367 | bottom_left_center = pcbnew.VECTOR2I(
368 | center_x_nm - half_width + radius_nm,
369 | center_y_nm + half_height - radius_nm
370 | )
371 |
372 | # Add arcs for corners
373 | self._add_corner_arc(top_left_center, radius_nm, 180, 270, layer)
374 | self._add_corner_arc(top_right_center, radius_nm, 270, 0, layer)
375 | self._add_corner_arc(bottom_right_center, radius_nm, 0, 90, layer)
376 | self._add_corner_arc(bottom_left_center, radius_nm, 90, 180, layer)
377 |
378 | # Add lines for straight edges
379 | # Top edge
380 | self._add_edge_line(
381 | pcbnew.VECTOR2I(top_left_center.x, top_left_center.y - radius_nm),
382 | pcbnew.VECTOR2I(top_right_center.x, top_right_center.y - radius_nm),
383 | layer
384 | )
385 | # Right edge
386 | self._add_edge_line(
387 | pcbnew.VECTOR2I(top_right_center.x + radius_nm, top_right_center.y),
388 | pcbnew.VECTOR2I(bottom_right_center.x + radius_nm, bottom_right_center.y),
389 | layer
390 | )
391 | # Bottom edge
392 | self._add_edge_line(
393 | pcbnew.VECTOR2I(bottom_right_center.x, bottom_right_center.y + radius_nm),
394 | pcbnew.VECTOR2I(bottom_left_center.x, bottom_left_center.y + radius_nm),
395 | layer
396 | )
397 | # Left edge
398 | self._add_edge_line(
399 | pcbnew.VECTOR2I(bottom_left_center.x - radius_nm, bottom_left_center.y),
400 | pcbnew.VECTOR2I(top_left_center.x - radius_nm, top_left_center.y),
401 | layer
402 | )
403 |
404 | def _add_corner_arc(self, center: pcbnew.VECTOR2I, radius: int,
405 | start_angle: float, end_angle: float, layer: int) -> None:
406 | """Add an arc for a rounded corner"""
407 | # Create arc for corner
408 | arc = pcbnew.PCB_SHAPE(self.board)
409 | arc.SetShape(pcbnew.SHAPE_T_ARC)
410 | arc.SetCenter(center)
411 |
412 | # Calculate start and end points
413 | start_x = center.x + int(radius * math.cos(math.radians(start_angle)))
414 | start_y = center.y + int(radius * math.sin(math.radians(start_angle)))
415 | end_x = center.x + int(radius * math.cos(math.radians(end_angle)))
416 | end_y = center.y + int(radius * math.sin(math.radians(end_angle)))
417 |
418 | arc.SetStart(pcbnew.VECTOR2I(start_x, start_y))
419 | arc.SetEnd(pcbnew.VECTOR2I(end_x, end_y))
420 | arc.SetLayer(layer)
421 | arc.SetWidth(0) # Zero width for edge cuts
422 | self.board.Add(arc)
423 |
```
--------------------------------------------------------------------------------
/python/commands/design_rules.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Design rules command implementations for KiCAD interface
3 | """
4 |
5 | import os
6 | import pcbnew
7 | import logging
8 | from typing import Dict, Any, Optional, List, Tuple
9 |
10 | logger = logging.getLogger('kicad_interface')
11 |
12 | class DesignRuleCommands:
13 | """Handles design rule checking and configuration"""
14 |
15 | def __init__(self, board: Optional[pcbnew.BOARD] = None):
16 | """Initialize with optional board instance"""
17 | self.board = board
18 |
19 | def set_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
20 | """Set design rules for the PCB"""
21 | try:
22 | if not self.board:
23 | return {
24 | "success": False,
25 | "message": "No board is loaded",
26 | "errorDetails": "Load or create a board first"
27 | }
28 |
29 | design_settings = self.board.GetDesignSettings()
30 |
31 | # Convert mm to nanometers for KiCAD internal units
32 | scale = 1000000 # mm to nm
33 |
34 | # Set clearance
35 | if "clearance" in params:
36 | design_settings.m_MinClearance = int(params["clearance"] * scale)
37 |
38 | # KiCAD 9.0: Use SetCustom* methods instead of SetCurrent* (which were removed)
39 | # Track if we set any custom track/via values
40 | custom_values_set = False
41 |
42 | if "trackWidth" in params:
43 | design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale))
44 | custom_values_set = True
45 |
46 | # Via settings
47 | if "viaDiameter" in params:
48 | design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale))
49 | custom_values_set = True
50 | if "viaDrill" in params:
51 | design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale))
52 | custom_values_set = True
53 |
54 | # KiCAD 9.0: Activate custom track/via values so they become the current values
55 | if custom_values_set:
56 | design_settings.UseCustomTrackViaSize(True)
57 |
58 | # Set micro via settings (use properties - methods removed in KiCAD 9.0)
59 | if "microViaDiameter" in params:
60 | design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
61 | if "microViaDrill" in params:
62 | design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
63 |
64 | # Set minimum values
65 | if "minTrackWidth" in params:
66 | design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale)
67 | if "minViaDiameter" in params:
68 | design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale)
69 |
70 | # KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead
71 | if "minViaDrill" in params:
72 | design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
73 |
74 | if "minMicroViaDiameter" in params:
75 | design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
76 | if "minMicroViaDrill" in params:
77 | design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
78 |
79 | # KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
80 | if "minHoleDiameter" in params:
81 | design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
82 |
83 | # KiCAD 9.0: Added hole clearance settings
84 | if "holeClearance" in params:
85 | design_settings.m_HoleClearance = int(params["holeClearance"] * scale)
86 | if "holeToHoleMin" in params:
87 | design_settings.m_HoleToHoleMin = int(params["holeToHoleMin"] * scale)
88 |
89 | # Build response with KiCAD 9.0 compatible properties
90 | # After UseCustomTrackViaSize(True), GetCurrent* returns the custom values
91 | response_rules = {
92 | "clearance": design_settings.m_MinClearance / scale,
93 | "trackWidth": design_settings.GetCurrentTrackWidth() / scale,
94 | "viaDiameter": design_settings.GetCurrentViaSize() / scale,
95 | "viaDrill": design_settings.GetCurrentViaDrill() / scale,
96 | "microViaDiameter": design_settings.m_MicroViasMinSize / scale,
97 | "microViaDrill": design_settings.m_MicroViasMinDrill / scale,
98 | "minTrackWidth": design_settings.m_TrackMinWidth / scale,
99 | "minViaDiameter": design_settings.m_ViasMinSize / scale,
100 | "minThroughDrill": design_settings.m_MinThroughDrill / scale,
101 | "minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
102 | "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
103 | "holeClearance": design_settings.m_HoleClearance / scale,
104 | "holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
105 | "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale
106 | }
107 |
108 | return {
109 | "success": True,
110 | "message": "Updated design rules",
111 | "rules": response_rules
112 | }
113 |
114 | except Exception as e:
115 | logger.error(f"Error setting design rules: {str(e)}")
116 | return {
117 | "success": False,
118 | "message": "Failed to set design rules",
119 | "errorDetails": str(e)
120 | }
121 |
122 | def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
123 | """Get current design rules - KiCAD 9.0 compatible"""
124 | try:
125 | if not self.board:
126 | return {
127 | "success": False,
128 | "message": "No board is loaded",
129 | "errorDetails": "Load or create a board first"
130 | }
131 |
132 | design_settings = self.board.GetDesignSettings()
133 | scale = 1000000 # nm to mm
134 |
135 | # Build rules dict with KiCAD 9.0 compatible properties
136 | rules = {
137 | # Core clearance and track settings
138 | "clearance": design_settings.m_MinClearance / scale,
139 | "trackWidth": design_settings.GetCurrentTrackWidth() / scale,
140 | "minTrackWidth": design_settings.m_TrackMinWidth / scale,
141 |
142 | # Via settings (current values from methods)
143 | "viaDiameter": design_settings.GetCurrentViaSize() / scale,
144 | "viaDrill": design_settings.GetCurrentViaDrill() / scale,
145 |
146 | # Via minimum values
147 | "minViaDiameter": design_settings.m_ViasMinSize / scale,
148 | "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
149 |
150 | # Micro via settings
151 | "microViaDiameter": design_settings.m_MicroViasMinSize / scale,
152 | "microViaDrill": design_settings.m_MicroViasMinDrill / scale,
153 | "minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
154 | "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
155 |
156 | # KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter)
157 | "minThroughDrill": design_settings.m_MinThroughDrill / scale,
158 | "holeClearance": design_settings.m_HoleClearance / scale,
159 | "holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
160 |
161 | # Other constraints
162 | "copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale,
163 | "silkClearance": design_settings.m_SilkClearance / scale,
164 | }
165 |
166 | return {
167 | "success": True,
168 | "rules": rules
169 | }
170 |
171 | except Exception as e:
172 | logger.error(f"Error getting design rules: {str(e)}")
173 | return {
174 | "success": False,
175 | "message": "Failed to get design rules",
176 | "errorDetails": str(e)
177 | }
178 |
179 | def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
180 | """Run Design Rule Check using kicad-cli"""
181 | import subprocess
182 | import json
183 | import tempfile
184 | import platform
185 | import shutil
186 |
187 | try:
188 | if not self.board:
189 | return {
190 | "success": False,
191 | "message": "No board is loaded",
192 | "errorDetails": "Load or create a board first"
193 | }
194 |
195 | report_path = params.get("reportPath")
196 |
197 | # Get the board file path
198 | board_file = self.board.GetFileName()
199 | if not board_file or not os.path.exists(board_file):
200 | return {
201 | "success": False,
202 | "message": "Board file not found",
203 | "errorDetails": "Cannot run DRC without a saved board file"
204 | }
205 |
206 | # Find kicad-cli executable
207 | kicad_cli = self._find_kicad_cli()
208 | if not kicad_cli:
209 | return {
210 | "success": False,
211 | "message": "kicad-cli not found",
212 | "errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH."
213 | }
214 |
215 | # Create temporary JSON output file
216 | with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
217 | json_output = tmp.name
218 |
219 | try:
220 | # Build command
221 | cmd = [
222 | kicad_cli,
223 | 'pcb',
224 | 'drc',
225 | '--format', 'json',
226 | '--output', json_output,
227 | '--units', 'mm',
228 | board_file
229 | ]
230 |
231 | logger.info(f"Running DRC command: {' '.join(cmd)}")
232 |
233 | # Run DRC
234 | result = subprocess.run(
235 | cmd,
236 | capture_output=True,
237 | text=True,
238 | timeout=600 # 10 minute timeout for large boards (21MB PCB needs time)
239 | )
240 |
241 | if result.returncode != 0:
242 | logger.error(f"DRC command failed: {result.stderr}")
243 | return {
244 | "success": False,
245 | "message": "DRC command failed",
246 | "errorDetails": result.stderr
247 | }
248 |
249 | # Read JSON output
250 | with open(json_output, 'r', encoding='utf-8') as f:
251 | drc_data = json.load(f)
252 |
253 | # Parse violations from kicad-cli output
254 | violations = []
255 | violation_counts = {}
256 | severity_counts = {"error": 0, "warning": 0, "info": 0}
257 |
258 | for violation in drc_data.get('violations', []):
259 | vtype = violation.get("type", "unknown")
260 | vseverity = violation.get("severity", "error")
261 |
262 | violations.append({
263 | "type": vtype,
264 | "severity": vseverity,
265 | "message": violation.get("description", ""),
266 | "location": {
267 | "x": violation.get("x", 0),
268 | "y": violation.get("y", 0),
269 | "unit": "mm"
270 | }
271 | })
272 |
273 | # Count violations by type
274 | violation_counts[vtype] = violation_counts.get(vtype, 0) + 1
275 |
276 | # Count by severity
277 | if vseverity in severity_counts:
278 | severity_counts[vseverity] += 1
279 |
280 | # Determine where to save the violations file
281 | board_dir = os.path.dirname(board_file)
282 | board_name = os.path.splitext(os.path.basename(board_file))[0]
283 | violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
284 |
285 | # Always save violations to JSON file (for large result sets)
286 | with open(violations_file, 'w', encoding='utf-8') as f:
287 | json.dump({
288 | "board": board_file,
289 | "timestamp": drc_data.get("date", "unknown"),
290 | "total_violations": len(violations),
291 | "violation_counts": violation_counts,
292 | "severity_counts": severity_counts,
293 | "violations": violations
294 | }, f, indent=2)
295 |
296 | # Save text report if requested
297 | if report_path:
298 | report_path = os.path.abspath(os.path.expanduser(report_path))
299 | cmd_report = [
300 | kicad_cli,
301 | 'pcb',
302 | 'drc',
303 | '--format', 'report',
304 | '--output', report_path,
305 | '--units', 'mm',
306 | board_file
307 | ]
308 | subprocess.run(cmd_report, capture_output=True, timeout=600)
309 |
310 | # Return summary only (not full violations list)
311 | return {
312 | "success": True,
313 | "message": f"Found {len(violations)} DRC violations",
314 | "summary": {
315 | "total": len(violations),
316 | "by_severity": severity_counts,
317 | "by_type": violation_counts
318 | },
319 | "violationsFile": violations_file,
320 | "reportPath": report_path if report_path else None
321 | }
322 |
323 | finally:
324 | # Clean up temp JSON file
325 | if os.path.exists(json_output):
326 | os.unlink(json_output)
327 |
328 | except subprocess.TimeoutExpired:
329 | logger.error("DRC command timed out")
330 | return {
331 | "success": False,
332 | "message": "DRC command timed out",
333 | "errorDetails": "Command took longer than 600 seconds (10 minutes)"
334 | }
335 | except Exception as e:
336 | logger.error(f"Error running DRC: {str(e)}")
337 | return {
338 | "success": False,
339 | "message": "Failed to run DRC",
340 | "errorDetails": str(e)
341 | }
342 |
343 | def _find_kicad_cli(self) -> Optional[str]:
344 | """Find kicad-cli executable"""
345 | import platform
346 | import shutil
347 |
348 | # Try system PATH first
349 | cli_name = "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli"
350 | cli_path = shutil.which(cli_name)
351 | if cli_path:
352 | return cli_path
353 |
354 | # Try common installation paths (version-specific)
355 | if platform.system() == "Windows":
356 | common_paths = [
357 | r"C:\Program Files\KiCad\10.0\bin\kicad-cli.exe",
358 | r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe",
359 | r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe",
360 | r"C:\Program Files (x86)\KiCad\10.0\bin\kicad-cli.exe",
361 | r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe",
362 | r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
363 | r"C:\Program Files\KiCad\bin\kicad-cli.exe",
364 | ]
365 | for path in common_paths:
366 | if os.path.exists(path):
367 | return path
368 | elif platform.system() == "Darwin": # macOS
369 | common_paths = [
370 | "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
371 | "/usr/local/bin/kicad-cli",
372 | ]
373 | for path in common_paths:
374 | if os.path.exists(path):
375 | return path
376 | else: # Linux
377 | common_paths = [
378 | "/usr/bin/kicad-cli",
379 | "/usr/local/bin/kicad-cli",
380 | ]
381 | for path in common_paths:
382 | if os.path.exists(path):
383 | return path
384 |
385 | return None
386 |
387 | def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
388 | """Get list of DRC violations"""
389 | try:
390 | if not self.board:
391 | return {
392 | "success": False,
393 | "message": "No board is loaded",
394 | "errorDetails": "Load or create a board first"
395 | }
396 |
397 | severity = params.get("severity", "all")
398 |
399 | # Get DRC markers
400 | violations = []
401 | for marker in self.board.GetDRCMarkers():
402 | violation = {
403 | "type": marker.GetErrorCode(),
404 | "severity": "error", # KiCAD DRC markers are always errors
405 | "message": marker.GetDescription(),
406 | "location": {
407 | "x": marker.GetPos().x / 1000000,
408 | "y": marker.GetPos().y / 1000000,
409 | "unit": "mm"
410 | }
411 | }
412 |
413 | # Filter by severity if specified
414 | if severity == "all" or severity == violation["severity"]:
415 | violations.append(violation)
416 |
417 | return {
418 | "success": True,
419 | "violations": violations
420 | }
421 |
422 | except Exception as e:
423 | logger.error(f"Error getting DRC violations: {str(e)}")
424 | return {
425 | "success": False,
426 | "message": "Failed to get DRC violations",
427 | "errorDetails": str(e)
428 | }
429 |
```
--------------------------------------------------------------------------------
/src/kicad-server.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3 | import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4 | import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5 | import { spawn, ChildProcess } from 'child_process';
6 | import { existsSync } from 'fs';
7 | import path from 'path';
8 |
9 | // Import all tool definitions for reference
10 | // import { registerBoardTools } from './tools/board.js';
11 | // import { registerComponentTools } from './tools/component.js';
12 | // import { registerRoutingTools } from './tools/routing.js';
13 | // import { registerDesignRuleTools } from './tools/design-rules.js';
14 | // import { registerExportTools } from './tools/export.js';
15 | // import { registerProjectTools } from './tools/project.js';
16 | // import { registerSchematicTools } from './tools/schematic.js';
17 |
18 | class KiCADServer {
19 | private server: Server;
20 | private pythonProcess: ChildProcess | null = null;
21 | private kicadScriptPath: string;
22 | private requestQueue: Array<{ request: any, resolve: Function, reject: Function }> = [];
23 | private processingRequest = false;
24 |
25 | constructor() {
26 | // Set absolute path to the Python KiCAD interface script
27 | // Using a hardcoded path to avoid cwd() issues when running from Cline
28 | this.kicadScriptPath = 'c:/repo/KiCAD-MCP/python/kicad_interface.py';
29 |
30 | // Check if script exists
31 | if (!existsSync(this.kicadScriptPath)) {
32 | throw new Error(`KiCAD interface script not found: ${this.kicadScriptPath}`);
33 | }
34 |
35 | // Initialize the server
36 | this.server = new Server(
37 | {
38 | name: 'kicad-mcp-server',
39 | version: '1.0.0'
40 | },
41 | {
42 | capabilities: {
43 | tools: {
44 | // Empty object here, tools will be registered dynamically
45 | }
46 | }
47 | }
48 | );
49 |
50 | // Initialize handler with direct pass-through to Python KiCAD interface
51 | // We don't register TypeScript tools since we'll handle everything in Python
52 |
53 | // Register tool list handler
54 | this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
55 | tools: [
56 | // Project tools
57 | {
58 | name: 'create_project',
59 | description: 'Create a new KiCAD project',
60 | inputSchema: {
61 | type: 'object',
62 | properties: {
63 | projectName: { type: 'string', description: 'Name of the new project' },
64 | path: { type: 'string', description: 'Path where to create the project' },
65 | template: { type: 'string', description: 'Optional template to use' }
66 | },
67 | required: ['projectName']
68 | }
69 | },
70 | {
71 | name: 'open_project',
72 | description: 'Open an existing KiCAD project',
73 | inputSchema: {
74 | type: 'object',
75 | properties: {
76 | filename: { type: 'string', description: 'Path to the project file' }
77 | },
78 | required: ['filename']
79 | }
80 | },
81 | {
82 | name: 'save_project',
83 | description: 'Save the current KiCAD project',
84 | inputSchema: {
85 | type: 'object',
86 | properties: {
87 | filename: { type: 'string', description: 'Optional path to save to' }
88 | }
89 | }
90 | },
91 | {
92 | name: 'get_project_info',
93 | description: 'Get information about the current project',
94 | inputSchema: {
95 | type: 'object',
96 | properties: {}
97 | }
98 | },
99 |
100 | // Board tools
101 | {
102 | name: 'set_board_size',
103 | description: 'Set the size of the PCB board',
104 | inputSchema: {
105 | type: 'object',
106 | properties: {
107 | width: { type: 'number', description: 'Board width' },
108 | height: { type: 'number', description: 'Board height' },
109 | unit: { type: 'string', description: 'Unit of measurement (mm or inch)' }
110 | },
111 | required: ['width', 'height']
112 | }
113 | },
114 | {
115 | name: 'add_board_outline',
116 | description: 'Add a board outline to the PCB',
117 | inputSchema: {
118 | type: 'object',
119 | properties: {
120 | shape: { type: 'string', description: 'Shape of outline (rectangle, circle, polygon, rounded_rectangle)' },
121 | width: { type: 'number', description: 'Width for rectangle shapes' },
122 | height: { type: 'number', description: 'Height for rectangle shapes' },
123 | radius: { type: 'number', description: 'Radius for circle shapes' },
124 | cornerRadius: { type: 'number', description: 'Corner radius for rounded rectangles' },
125 | points: { type: 'array', description: 'Array of points for polygon shapes' },
126 | centerX: { type: 'number', description: 'X coordinate of center' },
127 | centerY: { type: 'number', description: 'Y coordinate of center' },
128 | unit: { type: 'string', description: 'Unit of measurement (mm or inch)' }
129 | }
130 | }
131 | },
132 |
133 | // Component tools
134 | {
135 | name: 'place_component',
136 | description: 'Place a component on the PCB',
137 | inputSchema: {
138 | type: 'object',
139 | properties: {
140 | componentId: { type: 'string', description: 'Component ID/footprint to place' },
141 | position: { type: 'object', description: 'Position coordinates' },
142 | reference: { type: 'string', description: 'Component reference designator' },
143 | value: { type: 'string', description: 'Component value' },
144 | rotation: { type: 'number', description: 'Rotation angle in degrees' },
145 | layer: { type: 'string', description: 'Layer to place component on' }
146 | },
147 | required: ['componentId', 'position']
148 | }
149 | },
150 |
151 | // Routing tools
152 | {
153 | name: 'add_net',
154 | description: 'Add a new net to the PCB',
155 | inputSchema: {
156 | type: 'object',
157 | properties: {
158 | name: { type: 'string', description: 'Net name' },
159 | class: { type: 'string', description: 'Net class' }
160 | },
161 | required: ['name']
162 | }
163 | },
164 | {
165 | name: 'route_trace',
166 | description: 'Route a trace between two points or pads',
167 | inputSchema: {
168 | type: 'object',
169 | properties: {
170 | start: { type: 'object', description: 'Start point or pad' },
171 | end: { type: 'object', description: 'End point or pad' },
172 | layer: { type: 'string', description: 'Layer to route on' },
173 | width: { type: 'number', description: 'Track width' },
174 | net: { type: 'string', description: 'Net name' }
175 | },
176 | required: ['start', 'end']
177 | }
178 | },
179 |
180 | // Schematic tools
181 | {
182 | name: 'create_schematic',
183 | description: 'Create a new KiCAD schematic',
184 | inputSchema: {
185 | type: 'object',
186 | properties: {
187 | projectName: { type: 'string', description: 'Name of the schematic project' },
188 | path: { type: 'string', description: 'Path where to create the schematic file' },
189 | metadata: { type: 'object', description: 'Optional metadata for the schematic' }
190 | },
191 | required: ['projectName']
192 | }
193 | },
194 | {
195 | name: 'load_schematic',
196 | description: 'Load an existing KiCAD schematic',
197 | inputSchema: {
198 | type: 'object',
199 | properties: {
200 | filename: { type: 'string', description: 'Path to the schematic file to load' }
201 | },
202 | required: ['filename']
203 | }
204 | },
205 | {
206 | name: 'add_schematic_component',
207 | description: 'Add a component to a KiCAD schematic',
208 | inputSchema: {
209 | type: 'object',
210 | properties: {
211 | schematicPath: { type: 'string', description: 'Path to the schematic file' },
212 | component: {
213 | type: 'object',
214 | description: 'Component definition',
215 | properties: {
216 | type: { type: 'string', description: 'Component type (e.g., R, C, LED)' },
217 | reference: { type: 'string', description: 'Reference designator (e.g., R1, C2)' },
218 | value: { type: 'string', description: 'Component value (e.g., 10k, 0.1uF)' },
219 | library: { type: 'string', description: 'Symbol library name' },
220 | x: { type: 'number', description: 'X position in schematic' },
221 | y: { type: 'number', description: 'Y position in schematic' },
222 | rotation: { type: 'number', description: 'Rotation angle in degrees' },
223 | properties: { type: 'object', description: 'Additional properties' }
224 | },
225 | required: ['type', 'reference']
226 | }
227 | },
228 | required: ['schematicPath', 'component']
229 | }
230 | },
231 | {
232 | name: 'add_schematic_wire',
233 | description: 'Add a wire connection to a KiCAD schematic',
234 | inputSchema: {
235 | type: 'object',
236 | properties: {
237 | schematicPath: { type: 'string', description: 'Path to the schematic file' },
238 | startPoint: {
239 | type: 'array',
240 | description: 'Starting point coordinates [x, y]',
241 | items: { type: 'number' },
242 | minItems: 2,
243 | maxItems: 2
244 | },
245 | endPoint: {
246 | type: 'array',
247 | description: 'Ending point coordinates [x, y]',
248 | items: { type: 'number' },
249 | minItems: 2,
250 | maxItems: 2
251 | }
252 | },
253 | required: ['schematicPath', 'startPoint', 'endPoint']
254 | }
255 | },
256 | {
257 | name: 'list_schematic_libraries',
258 | description: 'List available KiCAD symbol libraries',
259 | inputSchema: {
260 | type: 'object',
261 | properties: {
262 | searchPaths: {
263 | type: 'array',
264 | description: 'Optional search paths for libraries',
265 | items: { type: 'string' }
266 | }
267 | }
268 | }
269 | },
270 | {
271 | name: 'export_schematic_pdf',
272 | description: 'Export a KiCAD schematic to PDF',
273 | inputSchema: {
274 | type: 'object',
275 | properties: {
276 | schematicPath: { type: 'string', description: 'Path to the schematic file' },
277 | outputPath: { type: 'string', description: 'Path for the output PDF file' }
278 | },
279 | required: ['schematicPath', 'outputPath']
280 | }
281 | }
282 | ]
283 | }));
284 |
285 | // Register tool call handler
286 | this.server.setRequestHandler(CallToolRequestSchema, async (request: any) => {
287 | const toolName = request.params.name;
288 | const args = request.params.arguments || {};
289 |
290 | // Pass all commands directly to KiCAD Python interface
291 | try {
292 | return await this.callKicadScript(toolName, args);
293 | } catch (error) {
294 | console.error(`Error executing tool ${toolName}:`, error);
295 | throw new Error(`Unknown tool: ${toolName}`);
296 | }
297 | });
298 | }
299 |
300 | async start() {
301 | try {
302 | console.error('Starting KiCAD MCP server...');
303 |
304 | // Start the Python process for KiCAD scripting
305 | console.error(`Starting Python process with script: ${this.kicadScriptPath}`);
306 | const pythonExe = 'C:\\Program Files\\KiCad\\9.0\\bin\\python.exe';
307 |
308 | console.error(`Using Python executable: ${pythonExe}`);
309 | this.pythonProcess = spawn(pythonExe, [this.kicadScriptPath], {
310 | stdio: ['pipe', 'pipe', 'pipe'],
311 | env: {
312 | ...process.env,
313 | PYTHONPATH: 'C:/Program Files/KiCad/9.0/lib/python3/dist-packages'
314 | }
315 | });
316 |
317 | // Listen for process exit
318 | this.pythonProcess.on('exit', (code, signal) => {
319 | console.error(`Python process exited with code ${code} and signal ${signal}`);
320 | this.pythonProcess = null;
321 | });
322 |
323 | // Listen for process errors
324 | this.pythonProcess.on('error', (err) => {
325 | console.error(`Python process error: ${err.message}`);
326 | });
327 |
328 | // Set up error logging for stderr
329 | if (this.pythonProcess.stderr) {
330 | this.pythonProcess.stderr.on('data', (data: Buffer) => {
331 | console.error(`Python stderr: ${data.toString()}`);
332 | });
333 | }
334 |
335 | // Connect to transport
336 | const transport = new StdioServerTransport();
337 | await this.server.connect(transport);
338 | console.error('KiCAD MCP server running');
339 |
340 | // Keep the process running
341 | process.on('SIGINT', () => {
342 | if (this.pythonProcess) {
343 | this.pythonProcess.kill();
344 | }
345 | this.server.close().catch(console.error);
346 | process.exit(0);
347 | });
348 |
349 | } catch (error: unknown) {
350 | if (error instanceof Error) {
351 | console.error('Failed to start MCP server:', error.message);
352 | } else {
353 | console.error('Failed to start MCP server: Unknown error');
354 | }
355 | process.exit(1);
356 | }
357 | }
358 |
359 | private async callKicadScript(command: string, params: any): Promise<any> {
360 | return new Promise((resolve, reject) => {
361 | // Check if Python process is running
362 | if (!this.pythonProcess) {
363 | console.error('Python process is not running');
364 | reject(new Error("Python process for KiCAD scripting is not running"));
365 | return;
366 | }
367 |
368 | // Add request to queue
369 | this.requestQueue.push({
370 | request: { command, params },
371 | resolve,
372 | reject
373 | });
374 |
375 | // Process the queue if not already processing
376 | if (!this.processingRequest) {
377 | this.processNextRequest();
378 | }
379 | });
380 | }
381 |
382 | private processNextRequest(): void {
383 | // If no more requests or already processing, return
384 | if (this.requestQueue.length === 0 || this.processingRequest) {
385 | return;
386 | }
387 |
388 | // Set processing flag
389 | this.processingRequest = true;
390 |
391 | // Get the next request
392 | const { request, resolve, reject } = this.requestQueue.shift()!;
393 |
394 | try {
395 | console.error(`Processing KiCAD command: ${request.command}`);
396 |
397 | // Format the command and parameters as JSON
398 | const requestStr = JSON.stringify(request);
399 |
400 | // Set up response handling
401 | let responseData = '';
402 |
403 | // Clear any previous listeners
404 | if (this.pythonProcess?.stdout) {
405 | this.pythonProcess.stdout.removeAllListeners('data');
406 | }
407 |
408 | // Set up new listeners
409 | if (this.pythonProcess?.stdout) {
410 | this.pythonProcess.stdout.on('data', (data: Buffer) => {
411 | const chunk = data.toString();
412 | console.error(`Received data chunk: ${chunk.length} bytes`);
413 | responseData += chunk;
414 |
415 | // Check if we have a complete response
416 | try {
417 | // Try to parse the response as JSON
418 | const result = JSON.parse(responseData);
419 |
420 | // If we get here, we have a valid JSON response
421 | console.error(`Completed KiCAD command: ${request.command} with result: ${JSON.stringify(result)}`);
422 |
423 | // Reset processing flag
424 | this.processingRequest = false;
425 |
426 | // Process next request if any
427 | setTimeout(() => this.processNextRequest(), 0);
428 |
429 | // Clear listeners
430 | if (this.pythonProcess?.stdout) {
431 | this.pythonProcess.stdout.removeAllListeners('data');
432 | }
433 |
434 | // Resolve with the expected MCP tool response format
435 | if (result.success) {
436 | resolve({
437 | content: [
438 | {
439 | type: 'text',
440 | text: JSON.stringify(result, null, 2)
441 | }
442 | ]
443 | });
444 | } else {
445 | resolve({
446 | content: [
447 | {
448 | type: 'text',
449 | text: result.errorDetails || result.message || 'Unknown error'
450 | }
451 | ],
452 | isError: true
453 | });
454 | }
455 | } catch (e) {
456 | // Not a complete JSON yet, keep collecting data
457 | }
458 | });
459 | }
460 |
461 | // Set a timeout
462 | const timeout = setTimeout(() => {
463 | console.error(`Command timeout: ${request.command}`);
464 |
465 | // Clear listeners
466 | if (this.pythonProcess?.stdout) {
467 | this.pythonProcess.stdout.removeAllListeners('data');
468 | }
469 |
470 | // Reset processing flag
471 | this.processingRequest = false;
472 |
473 | // Process next request
474 | setTimeout(() => this.processNextRequest(), 0);
475 |
476 | // Reject the promise
477 | reject(new Error(`Command timeout: ${request.command}`));
478 | }, 30000); // 30 seconds timeout
479 |
480 | // Write the request to the Python process
481 | console.error(`Sending request: ${requestStr}`);
482 | this.pythonProcess?.stdin?.write(requestStr + '\n');
483 | } catch (error) {
484 | console.error(`Error processing request: ${error}`);
485 |
486 | // Reset processing flag
487 | this.processingRequest = false;
488 |
489 | // Process next request
490 | setTimeout(() => this.processNextRequest(), 0);
491 |
492 | // Reject the promise
493 | reject(error);
494 | }
495 | }
496 | }
497 |
498 | // Start the server
499 | const server = new KiCADServer();
500 | server.start().catch(console.error);
501 |
```
--------------------------------------------------------------------------------
/python/commands/export.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Export command implementations for KiCAD interface
3 | """
4 |
5 | import os
6 | import pcbnew
7 | import logging
8 | from typing import Dict, Any, Optional, List, Tuple
9 | import base64
10 |
11 | logger = logging.getLogger('kicad_interface')
12 |
13 | class ExportCommands:
14 | """Handles export-related KiCAD operations"""
15 |
16 | def __init__(self, board: Optional[pcbnew.BOARD] = None):
17 | """Initialize with optional board instance"""
18 | self.board = board
19 |
20 | def export_gerber(self, params: Dict[str, Any]) -> Dict[str, Any]:
21 | """Export Gerber files"""
22 | try:
23 | if not self.board:
24 | return {
25 | "success": False,
26 | "message": "No board is loaded",
27 | "errorDetails": "Load or create a board first"
28 | }
29 |
30 | output_dir = params.get("outputDir")
31 | layers = params.get("layers", [])
32 | use_protel_extensions = params.get("useProtelExtensions", False)
33 | generate_drill_files = params.get("generateDrillFiles", True)
34 | generate_map_file = params.get("generateMapFile", False)
35 | use_aux_origin = params.get("useAuxOrigin", False)
36 |
37 | if not output_dir:
38 | return {
39 | "success": False,
40 | "message": "Missing output directory",
41 | "errorDetails": "outputDir parameter is required"
42 | }
43 |
44 | # Create output directory if it doesn't exist
45 | output_dir = os.path.abspath(os.path.expanduser(output_dir))
46 | os.makedirs(output_dir, exist_ok=True)
47 |
48 | # Create plot controller
49 | plotter = pcbnew.PLOT_CONTROLLER(self.board)
50 |
51 | # Set up plot options
52 | plot_opts = plotter.GetPlotOptions()
53 | plot_opts.SetOutputDirectory(output_dir)
54 | plot_opts.SetFormat(pcbnew.PLOT_FORMAT_GERBER)
55 | plot_opts.SetUseGerberProtelExtensions(use_protel_extensions)
56 | plot_opts.SetUseAuxOrigin(use_aux_origin)
57 | plot_opts.SetCreateGerberJobFile(generate_map_file)
58 | plot_opts.SetSubtractMaskFromSilk(True)
59 |
60 | # Plot specified layers or all copper layers
61 | plotted_layers = []
62 | if layers:
63 | for layer_name in layers:
64 | layer_id = self.board.GetLayerID(layer_name)
65 | if layer_id >= 0:
66 | plotter.SetLayer(layer_id)
67 | plotter.PlotLayer()
68 | plotted_layers.append(layer_name)
69 | else:
70 | for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
71 | if self.board.IsLayerEnabled(layer_id):
72 | layer_name = self.board.GetLayerName(layer_id)
73 | plotter.SetLayer(layer_id)
74 | plotter.PlotLayer()
75 | plotted_layers.append(layer_name)
76 |
77 | # Generate drill files if requested
78 | drill_files = []
79 | if generate_drill_files:
80 | # KiCAD 9.0: Use kicad-cli for more reliable drill file generation
81 | # The Python API's EXCELLON_WRITER.SetOptions() signature changed
82 | board_file = self.board.GetFileName()
83 | kicad_cli = self._find_kicad_cli()
84 |
85 | if kicad_cli and board_file and os.path.exists(board_file):
86 | import subprocess
87 | # Generate drill files using kicad-cli
88 | cmd = [
89 | kicad_cli,
90 | 'pcb', 'export', 'drill',
91 | '--output', output_dir,
92 | '--format', 'excellon',
93 | '--drill-origin', 'absolute',
94 | '--excellon-separate-th', # Separate plated/non-plated
95 | board_file
96 | ]
97 |
98 | try:
99 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
100 | if result.returncode == 0:
101 | # Get list of generated drill files
102 | for file in os.listdir(output_dir):
103 | if file.endswith((".drl", ".cnc")):
104 | drill_files.append(file)
105 | else:
106 | logger.warning(f"Drill file generation failed: {result.stderr}")
107 | except Exception as drill_error:
108 | logger.warning(f"Could not generate drill files: {str(drill_error)}")
109 | else:
110 | logger.warning("kicad-cli not available for drill file generation")
111 |
112 | return {
113 | "success": True,
114 | "message": "Exported Gerber files",
115 | "files": {
116 | "gerber": plotted_layers,
117 | "drill": drill_files,
118 | "map": ["job.gbrjob"] if generate_map_file else []
119 | },
120 | "outputDir": output_dir
121 | }
122 |
123 | except Exception as e:
124 | logger.error(f"Error exporting Gerber files: {str(e)}")
125 | return {
126 | "success": False,
127 | "message": "Failed to export Gerber files",
128 | "errorDetails": str(e)
129 | }
130 |
131 | def export_pdf(self, params: Dict[str, Any]) -> Dict[str, Any]:
132 | """Export PDF files"""
133 | try:
134 | if not self.board:
135 | return {
136 | "success": False,
137 | "message": "No board is loaded",
138 | "errorDetails": "Load or create a board first"
139 | }
140 |
141 | output_path = params.get("outputPath")
142 | layers = params.get("layers", [])
143 | black_and_white = params.get("blackAndWhite", False)
144 | frame_reference = params.get("frameReference", True)
145 | page_size = params.get("pageSize", "A4")
146 |
147 | if not output_path:
148 | return {
149 | "success": False,
150 | "message": "Missing output path",
151 | "errorDetails": "outputPath parameter is required"
152 | }
153 |
154 | # Create output directory if it doesn't exist
155 | output_path = os.path.abspath(os.path.expanduser(output_path))
156 | os.makedirs(os.path.dirname(output_path), exist_ok=True)
157 |
158 | # Create plot controller
159 | plotter = pcbnew.PLOT_CONTROLLER(self.board)
160 |
161 | # Set up plot options
162 | plot_opts = plotter.GetPlotOptions()
163 | plot_opts.SetOutputDirectory(os.path.dirname(output_path))
164 | plot_opts.SetFormat(pcbnew.PLOT_FORMAT_PDF)
165 | plot_opts.SetPlotFrameRef(frame_reference)
166 | plot_opts.SetPlotValue(True)
167 | plot_opts.SetPlotReference(True)
168 | plot_opts.SetBlackAndWhite(black_and_white)
169 |
170 | # KiCAD 9.0 page size handling:
171 | # - SetPageSettings() was removed in KiCAD 9.0
172 | # - SetA4Output(bool) forces A4 page size when True
173 | # - For other sizes, KiCAD auto-scales to fit the board
174 | # - SetAutoScale(True) enables automatic scaling to fit page
175 | if page_size == "A4":
176 | plot_opts.SetA4Output(True)
177 | else:
178 | # For non-A4 sizes, disable A4 forcing and use auto-scale
179 | plot_opts.SetA4Output(False)
180 | plot_opts.SetAutoScale(True)
181 | # Note: KiCAD 9.0 doesn't support explicit page size selection
182 | # for formats other than A4. The PDF will auto-scale to fit.
183 | logger.warning(f"Page size '{page_size}' requested, but KiCAD 9.0 only supports A4 explicitly. Using auto-scale instead.")
184 |
185 | # Open plot for writing
186 | # Note: For PDF, all layers are combined into a single file
187 | # KiCAD prepends the board filename to the plot file name
188 | base_name = os.path.basename(output_path).replace('.pdf', '')
189 | plotter.OpenPlotfile(base_name, pcbnew.PLOT_FORMAT_PDF, '')
190 |
191 | # Plot specified layers or all enabled layers
192 | plotted_layers = []
193 | if layers:
194 | for layer_name in layers:
195 | layer_id = self.board.GetLayerID(layer_name)
196 | if layer_id >= 0:
197 | plotter.SetLayer(layer_id)
198 | plotter.PlotLayer()
199 | plotted_layers.append(layer_name)
200 | else:
201 | for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
202 | if self.board.IsLayerEnabled(layer_id):
203 | layer_name = self.board.GetLayerName(layer_id)
204 | plotter.SetLayer(layer_id)
205 | plotter.PlotLayer()
206 | plotted_layers.append(layer_name)
207 |
208 | # Close the plot file to finalize the PDF
209 | plotter.ClosePlot()
210 |
211 | # KiCAD automatically prepends the board name to the output file
212 | # Get the actual output filename that was created
213 | board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0]
214 | actual_filename = f"{board_name}-{base_name}.pdf"
215 | actual_output_path = os.path.join(os.path.dirname(output_path), actual_filename)
216 |
217 | return {
218 | "success": True,
219 | "message": "Exported PDF file",
220 | "file": {
221 | "path": actual_output_path,
222 | "requestedPath": output_path,
223 | "layers": plotted_layers,
224 | "pageSize": page_size if page_size == "A4" else "auto-scaled"
225 | }
226 | }
227 |
228 | except Exception as e:
229 | logger.error(f"Error exporting PDF file: {str(e)}")
230 | return {
231 | "success": False,
232 | "message": "Failed to export PDF file",
233 | "errorDetails": str(e)
234 | }
235 |
236 | def export_svg(self, params: Dict[str, Any]) -> Dict[str, Any]:
237 | """Export SVG files"""
238 | try:
239 | if not self.board:
240 | return {
241 | "success": False,
242 | "message": "No board is loaded",
243 | "errorDetails": "Load or create a board first"
244 | }
245 |
246 | output_path = params.get("outputPath")
247 | layers = params.get("layers", [])
248 | black_and_white = params.get("blackAndWhite", False)
249 | include_components = params.get("includeComponents", True)
250 |
251 | if not output_path:
252 | return {
253 | "success": False,
254 | "message": "Missing output path",
255 | "errorDetails": "outputPath parameter is required"
256 | }
257 |
258 | # Create output directory if it doesn't exist
259 | output_path = os.path.abspath(os.path.expanduser(output_path))
260 | os.makedirs(os.path.dirname(output_path), exist_ok=True)
261 |
262 | # Create plot controller
263 | plotter = pcbnew.PLOT_CONTROLLER(self.board)
264 |
265 | # Set up plot options
266 | plot_opts = plotter.GetPlotOptions()
267 | plot_opts.SetOutputDirectory(os.path.dirname(output_path))
268 | plot_opts.SetFormat(pcbnew.PLOT_FORMAT_SVG)
269 | plot_opts.SetPlotValue(include_components)
270 | plot_opts.SetPlotReference(include_components)
271 | plot_opts.SetBlackAndWhite(black_and_white)
272 |
273 | # Plot specified layers or all enabled layers
274 | plotted_layers = []
275 | if layers:
276 | for layer_name in layers:
277 | layer_id = self.board.GetLayerID(layer_name)
278 | if layer_id >= 0:
279 | plotter.SetLayer(layer_id)
280 | plotter.PlotLayer()
281 | plotted_layers.append(layer_name)
282 | else:
283 | for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
284 | if self.board.IsLayerEnabled(layer_id):
285 | layer_name = self.board.GetLayerName(layer_id)
286 | plotter.SetLayer(layer_id)
287 | plotter.PlotLayer()
288 | plotted_layers.append(layer_name)
289 |
290 | return {
291 | "success": True,
292 | "message": "Exported SVG file",
293 | "file": {
294 | "path": output_path,
295 | "layers": plotted_layers
296 | }
297 | }
298 |
299 | except Exception as e:
300 | logger.error(f"Error exporting SVG file: {str(e)}")
301 | return {
302 | "success": False,
303 | "message": "Failed to export SVG file",
304 | "errorDetails": str(e)
305 | }
306 |
307 | def export_3d(self, params: Dict[str, Any]) -> Dict[str, Any]:
308 | """Export 3D model files using kicad-cli (KiCAD 9.0 compatible)"""
309 | import subprocess
310 | import platform
311 | import shutil
312 |
313 | try:
314 | if not self.board:
315 | return {
316 | "success": False,
317 | "message": "No board is loaded",
318 | "errorDetails": "Load or create a board first"
319 | }
320 |
321 | output_path = params.get("outputPath")
322 | format = params.get("format", "STEP")
323 | include_components = params.get("includeComponents", True)
324 | include_copper = params.get("includeCopper", True)
325 | include_solder_mask = params.get("includeSolderMask", True)
326 | include_silkscreen = params.get("includeSilkscreen", True)
327 |
328 | if not output_path:
329 | return {
330 | "success": False,
331 | "message": "Missing output path",
332 | "errorDetails": "outputPath parameter is required"
333 | }
334 |
335 | # Get board file path
336 | board_file = self.board.GetFileName()
337 | if not board_file or not os.path.exists(board_file):
338 | return {
339 | "success": False,
340 | "message": "Board file not found",
341 | "errorDetails": "Board must be saved before exporting 3D models"
342 | }
343 |
344 | # Create output directory if it doesn't exist
345 | output_path = os.path.abspath(os.path.expanduser(output_path))
346 | os.makedirs(os.path.dirname(output_path), exist_ok=True)
347 |
348 | # Find kicad-cli executable
349 | kicad_cli = self._find_kicad_cli()
350 | if not kicad_cli:
351 | return {
352 | "success": False,
353 | "message": "kicad-cli not found",
354 | "errorDetails": "KiCAD CLI tool not found. Install KiCAD 8.0+ or set PATH."
355 | }
356 |
357 | # Build command based on format
358 | format_upper = format.upper()
359 |
360 | if format_upper == "STEP":
361 | cmd = [
362 | kicad_cli,
363 | 'pcb', 'export', 'step',
364 | '--output', output_path,
365 | '--force' # Overwrite existing file
366 | ]
367 |
368 | # Add options based on parameters
369 | if not include_components:
370 | cmd.append('--no-components')
371 | if include_copper:
372 | cmd.extend(['--include-tracks', '--include-pads', '--include-zones'])
373 | if include_silkscreen:
374 | cmd.append('--include-silkscreen')
375 | if include_solder_mask:
376 | cmd.append('--include-soldermask')
377 |
378 | cmd.append(board_file)
379 |
380 | elif format_upper == "VRML":
381 | cmd = [
382 | kicad_cli,
383 | 'pcb', 'export', 'vrml',
384 | '--output', output_path,
385 | '--units', 'mm', # Use mm for consistency
386 | '--force'
387 | ]
388 |
389 | if not include_components:
390 | # Note: VRML export doesn't have a direct --no-components flag
391 | # The models will be included by default, but can be controlled via 3D settings
392 | pass
393 |
394 | cmd.append(board_file)
395 |
396 | else:
397 | return {
398 | "success": False,
399 | "message": "Unsupported format",
400 | "errorDetails": f"Format {format} is not supported. Use 'STEP' or 'VRML'."
401 | }
402 |
403 | # Execute kicad-cli command
404 | logger.info(f"Running 3D export command: {' '.join(cmd)}")
405 |
406 | result = subprocess.run(
407 | cmd,
408 | capture_output=True,
409 | text=True,
410 | timeout=300 # 5 minute timeout for 3D export
411 | )
412 |
413 | if result.returncode != 0:
414 | logger.error(f"3D export command failed: {result.stderr}")
415 | return {
416 | "success": False,
417 | "message": "3D export command failed",
418 | "errorDetails": result.stderr
419 | }
420 |
421 | return {
422 | "success": True,
423 | "message": f"Exported {format_upper} file",
424 | "file": {
425 | "path": output_path,
426 | "format": format_upper
427 | }
428 | }
429 |
430 | except subprocess.TimeoutExpired:
431 | logger.error("3D export command timed out")
432 | return {
433 | "success": False,
434 | "message": "3D export timed out",
435 | "errorDetails": "Export took longer than 5 minutes"
436 | }
437 | except Exception as e:
438 | logger.error(f"Error exporting 3D model: {str(e)}")
439 | return {
440 | "success": False,
441 | "message": "Failed to export 3D model",
442 | "errorDetails": str(e)
443 | }
444 |
445 | def export_bom(self, params: Dict[str, Any]) -> Dict[str, Any]:
446 | """Export Bill of Materials"""
447 | try:
448 | if not self.board:
449 | return {
450 | "success": False,
451 | "message": "No board is loaded",
452 | "errorDetails": "Load or create a board first"
453 | }
454 |
455 | output_path = params.get("outputPath")
456 | format = params.get("format", "CSV")
457 | group_by_value = params.get("groupByValue", True)
458 | include_attributes = params.get("includeAttributes", [])
459 |
460 | if not output_path:
461 | return {
462 | "success": False,
463 | "message": "Missing output path",
464 | "errorDetails": "outputPath parameter is required"
465 | }
466 |
467 | # Create output directory if it doesn't exist
468 | output_path = os.path.abspath(os.path.expanduser(output_path))
469 | os.makedirs(os.path.dirname(output_path), exist_ok=True)
470 |
471 | # Get all components
472 | components = []
473 | for module in self.board.GetFootprints():
474 | component = {
475 | "reference": module.GetReference(),
476 | "value": module.GetValue(),
477 | "footprint": str(module.GetFPID()),
478 | "layer": self.board.GetLayerName(module.GetLayer())
479 | }
480 |
481 | # Add requested attributes
482 | for attr in include_attributes:
483 | if hasattr(module, f"Get{attr}"):
484 | component[attr] = getattr(module, f"Get{attr}")()
485 |
486 | components.append(component)
487 |
488 | # Group by value if requested
489 | if group_by_value:
490 | grouped = {}
491 | for comp in components:
492 | key = f"{comp['value']}_{comp['footprint']}"
493 | if key not in grouped:
494 | grouped[key] = {
495 | "value": comp["value"],
496 | "footprint": comp["footprint"],
497 | "quantity": 1,
498 | "references": [comp["reference"]]
499 | }
500 | else:
501 | grouped[key]["quantity"] += 1
502 | grouped[key]["references"].append(comp["reference"])
503 | components = list(grouped.values())
504 |
505 | # Export based on format
506 | if format == "CSV":
507 | self._export_bom_csv(output_path, components)
508 | elif format == "XML":
509 | self._export_bom_xml(output_path, components)
510 | elif format == "HTML":
511 | self._export_bom_html(output_path, components)
512 | elif format == "JSON":
513 | self._export_bom_json(output_path, components)
514 | else:
515 | return {
516 | "success": False,
517 | "message": "Unsupported format",
518 | "errorDetails": f"Format {format} is not supported"
519 | }
520 |
521 | return {
522 | "success": True,
523 | "message": f"Exported BOM to {format}",
524 | "file": {
525 | "path": output_path,
526 | "format": format,
527 | "componentCount": len(components)
528 | }
529 | }
530 |
531 | except Exception as e:
532 | logger.error(f"Error exporting BOM: {str(e)}")
533 | return {
534 | "success": False,
535 | "message": "Failed to export BOM",
536 | "errorDetails": str(e)
537 | }
538 |
539 | def _export_bom_csv(self, path: str, components: List[Dict[str, Any]]) -> None:
540 | """Export BOM to CSV format"""
541 | import csv
542 | with open(path, 'w', newline='') as f:
543 | writer = csv.DictWriter(f, fieldnames=components[0].keys())
544 | writer.writeheader()
545 | writer.writerows(components)
546 |
547 | def _export_bom_xml(self, path: str, components: List[Dict[str, Any]]) -> None:
548 | """Export BOM to XML format"""
549 | import xml.etree.ElementTree as ET
550 | root = ET.Element("bom")
551 | for comp in components:
552 | comp_elem = ET.SubElement(root, "component")
553 | for key, value in comp.items():
554 | elem = ET.SubElement(comp_elem, key)
555 | elem.text = str(value)
556 | tree = ET.ElementTree(root)
557 | tree.write(path, encoding='utf-8', xml_declaration=True)
558 |
559 | def _export_bom_html(self, path: str, components: List[Dict[str, Any]]) -> None:
560 | """Export BOM to HTML format"""
561 | html = ["<html><head><title>Bill of Materials</title></head><body>"]
562 | html.append("<table border='1'><tr>")
563 | # Headers
564 | for key in components[0].keys():
565 | html.append(f"<th>{key}</th>")
566 | html.append("</tr>")
567 | # Data
568 | for comp in components:
569 | html.append("<tr>")
570 | for value in comp.values():
571 | html.append(f"<td>{value}</td>")
572 | html.append("</tr>")
573 | html.append("</table></body></html>")
574 | with open(path, 'w') as f:
575 | f.write("\n".join(html))
576 |
577 | def _export_bom_json(self, path: str, components: List[Dict[str, Any]]) -> None:
578 | """Export BOM to JSON format"""
579 | import json
580 | with open(path, 'w') as f:
581 | json.dump({"components": components}, f, indent=2)
582 |
583 | def _find_kicad_cli(self) -> Optional[str]:
584 | """Find kicad-cli executable in system PATH or common locations
585 |
586 | Returns:
587 | Path to kicad-cli executable, or None if not found
588 | """
589 | import shutil
590 | import platform
591 |
592 | # Try system PATH first
593 | cli_path = shutil.which("kicad-cli")
594 | if cli_path:
595 | return cli_path
596 |
597 | # Try platform-specific default locations
598 | system = platform.system()
599 |
600 | if system == "Windows":
601 | possible_paths = [
602 | r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe",
603 | r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe",
604 | r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe",
605 | r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
606 | ]
607 | elif system == "Darwin": # macOS
608 | possible_paths = [
609 | "/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
610 | "/usr/local/bin/kicad-cli",
611 | ]
612 | else: # Linux
613 | possible_paths = [
614 | "/usr/bin/kicad-cli",
615 | "/usr/local/bin/kicad-cli",
616 | ]
617 |
618 | for path in possible_paths:
619 | if os.path.exists(path):
620 | return path
621 |
622 | return None
623 |
```
--------------------------------------------------------------------------------
/python/commands/routing.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Routing-related command implementations for KiCAD interface
3 | """
4 |
5 | import os
6 | import pcbnew
7 | import logging
8 | import math
9 | from typing import Dict, Any, Optional, List, Tuple
10 |
11 | logger = logging.getLogger('kicad_interface')
12 |
13 | class RoutingCommands:
14 | """Handles routing-related KiCAD operations"""
15 |
16 | def __init__(self, board: Optional[pcbnew.BOARD] = None):
17 | """Initialize with optional board instance"""
18 | self.board = board
19 |
20 | def add_net(self, params: Dict[str, Any]) -> Dict[str, Any]:
21 | """Add a new net to the PCB"""
22 | try:
23 | if not self.board:
24 | return {
25 | "success": False,
26 | "message": "No board is loaded",
27 | "errorDetails": "Load or create a board first"
28 | }
29 |
30 | name = params.get("name")
31 | net_class = params.get("class")
32 |
33 | if not name:
34 | return {
35 | "success": False,
36 | "message": "Missing net name",
37 | "errorDetails": "name parameter is required"
38 | }
39 |
40 | # Create new net
41 | netinfo = self.board.GetNetInfo()
42 | nets_map = netinfo.NetsByName()
43 | if nets_map.has_key(name):
44 | net = nets_map[name]
45 | else:
46 | net = pcbnew.NETINFO_ITEM(self.board, name)
47 | self.board.Add(net)
48 |
49 | # Set net class if provided
50 | if net_class:
51 | net_classes = self.board.GetNetClasses()
52 | if net_classes.Find(net_class):
53 | net.SetClass(net_classes.Find(net_class))
54 |
55 | return {
56 | "success": True,
57 | "message": f"Added net: {name}",
58 | "net": {
59 | "name": name,
60 | "class": net_class if net_class else "Default",
61 | "netcode": net.GetNetCode()
62 | }
63 | }
64 |
65 | except Exception as e:
66 | logger.error(f"Error adding net: {str(e)}")
67 | return {
68 | "success": False,
69 | "message": "Failed to add net",
70 | "errorDetails": str(e)
71 | }
72 |
73 | def route_trace(self, params: Dict[str, Any]) -> Dict[str, Any]:
74 | """Route a trace between two points or pads"""
75 | try:
76 | if not self.board:
77 | return {
78 | "success": False,
79 | "message": "No board is loaded",
80 | "errorDetails": "Load or create a board first"
81 | }
82 |
83 | start = params.get("start")
84 | end = params.get("end")
85 | layer = params.get("layer", "F.Cu")
86 | width = params.get("width")
87 | net = params.get("net")
88 | via = params.get("via", False)
89 |
90 | if not start or not end:
91 | return {
92 | "success": False,
93 | "message": "Missing parameters",
94 | "errorDetails": "start and end points are required"
95 | }
96 |
97 | # Get layer ID
98 | layer_id = self.board.GetLayerID(layer)
99 | if layer_id < 0:
100 | return {
101 | "success": False,
102 | "message": "Invalid layer",
103 | "errorDetails": f"Layer '{layer}' does not exist"
104 | }
105 |
106 | # Get start point
107 | start_point = self._get_point(start)
108 | end_point = self._get_point(end)
109 |
110 | # Create track segment
111 | track = pcbnew.PCB_TRACK(self.board)
112 | track.SetStart(start_point)
113 | track.SetEnd(end_point)
114 | track.SetLayer(layer_id)
115 |
116 | # Set width (default to board's current track width)
117 | if width:
118 | track.SetWidth(int(width * 1000000)) # Convert mm to nm
119 | else:
120 | track.SetWidth(self.board.GetDesignSettings().GetCurrentTrackWidth())
121 |
122 | # Set net if provided
123 | if net:
124 | netinfo = self.board.GetNetInfo()
125 | nets_map = netinfo.NetsByName()
126 | if nets_map.has_key(net):
127 | net_obj = nets_map[net]
128 | track.SetNet(net_obj)
129 |
130 | # Add track to board
131 | self.board.Add(track)
132 |
133 | # Add via if requested and net is specified
134 | if via and net:
135 | via_point = end_point
136 | self.add_via({
137 | "position": {
138 | "x": via_point.x / 1000000,
139 | "y": via_point.y / 1000000,
140 | "unit": "mm"
141 | },
142 | "net": net
143 | })
144 |
145 | return {
146 | "success": True,
147 | "message": "Added trace",
148 | "trace": {
149 | "start": {
150 | "x": start_point.x / 1000000,
151 | "y": start_point.y / 1000000,
152 | "unit": "mm"
153 | },
154 | "end": {
155 | "x": end_point.x / 1000000,
156 | "y": end_point.y / 1000000,
157 | "unit": "mm"
158 | },
159 | "layer": layer,
160 | "width": track.GetWidth() / 1000000,
161 | "net": net
162 | }
163 | }
164 |
165 | except Exception as e:
166 | logger.error(f"Error routing trace: {str(e)}")
167 | return {
168 | "success": False,
169 | "message": "Failed to route trace",
170 | "errorDetails": str(e)
171 | }
172 |
173 | def add_via(self, params: Dict[str, Any]) -> Dict[str, Any]:
174 | """Add a via at the specified location"""
175 | try:
176 | if not self.board:
177 | return {
178 | "success": False,
179 | "message": "No board is loaded",
180 | "errorDetails": "Load or create a board first"
181 | }
182 |
183 | position = params.get("position")
184 | size = params.get("size")
185 | drill = params.get("drill")
186 | net = params.get("net")
187 | from_layer = params.get("from_layer", "F.Cu")
188 | to_layer = params.get("to_layer", "B.Cu")
189 |
190 | if not position:
191 | return {
192 | "success": False,
193 | "message": "Missing position",
194 | "errorDetails": "position parameter is required"
195 | }
196 |
197 | # Create via
198 | via = pcbnew.PCB_VIA(self.board)
199 |
200 | # Set position
201 | scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
202 | x_nm = int(position["x"] * scale)
203 | y_nm = int(position["y"] * scale)
204 | via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
205 |
206 | # Set size and drill (default to board's current via settings)
207 | design_settings = self.board.GetDesignSettings()
208 | via.SetWidth(int(size * 1000000) if size else design_settings.GetCurrentViaSize())
209 | via.SetDrill(int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill())
210 |
211 | # Set layers
212 | from_id = self.board.GetLayerID(from_layer)
213 | to_id = self.board.GetLayerID(to_layer)
214 | if from_id < 0 or to_id < 0:
215 | return {
216 | "success": False,
217 | "message": "Invalid layer",
218 | "errorDetails": "Specified layers do not exist"
219 | }
220 | via.SetLayerPair(from_id, to_id)
221 |
222 | # Set net if provided
223 | if net:
224 | netinfo = self.board.GetNetInfo()
225 | nets_map = netinfo.NetsByName()
226 | if nets_map.has_key(net):
227 | net_obj = nets_map[net]
228 | via.SetNet(net_obj)
229 |
230 | # Add via to board
231 | self.board.Add(via)
232 |
233 | return {
234 | "success": True,
235 | "message": "Added via",
236 | "via": {
237 | "position": {
238 | "x": position["x"],
239 | "y": position["y"],
240 | "unit": position["unit"]
241 | },
242 | "size": via.GetWidth() / 1000000,
243 | "drill": via.GetDrill() / 1000000,
244 | "from_layer": from_layer,
245 | "to_layer": to_layer,
246 | "net": net
247 | }
248 | }
249 |
250 | except Exception as e:
251 | logger.error(f"Error adding via: {str(e)}")
252 | return {
253 | "success": False,
254 | "message": "Failed to add via",
255 | "errorDetails": str(e)
256 | }
257 |
258 | def delete_trace(self, params: Dict[str, Any]) -> Dict[str, Any]:
259 | """Delete a trace from the PCB"""
260 | try:
261 | if not self.board:
262 | return {
263 | "success": False,
264 | "message": "No board is loaded",
265 | "errorDetails": "Load or create a board first"
266 | }
267 |
268 | trace_uuid = params.get("traceUuid")
269 | position = params.get("position")
270 |
271 | if not trace_uuid and not position:
272 | return {
273 | "success": False,
274 | "message": "Missing parameters",
275 | "errorDetails": "Either traceUuid or position must be provided"
276 | }
277 |
278 | # Find track by UUID
279 | if trace_uuid:
280 | track = None
281 | for item in self.board.Tracks():
282 | if str(item.m_Uuid) == trace_uuid:
283 | track = item
284 | break
285 |
286 | if not track:
287 | return {
288 | "success": False,
289 | "message": "Track not found",
290 | "errorDetails": f"Could not find track with UUID: {trace_uuid}"
291 | }
292 |
293 | self.board.Remove(track)
294 | return {
295 | "success": True,
296 | "message": f"Deleted track: {trace_uuid}"
297 | }
298 |
299 | # Find track by position
300 | if position:
301 | scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
302 | x_nm = int(position["x"] * scale)
303 | y_nm = int(position["y"] * scale)
304 | point = pcbnew.VECTOR2I(x_nm, y_nm)
305 |
306 | # Find closest track
307 | closest_track = None
308 | min_distance = float('inf')
309 | for track in self.board.Tracks():
310 | dist = self._point_to_track_distance(point, track)
311 | if dist < min_distance:
312 | min_distance = dist
313 | closest_track = track
314 |
315 | if closest_track and min_distance < 1000000: # Within 1mm
316 | self.board.Remove(closest_track)
317 | return {
318 | "success": True,
319 | "message": "Deleted track at specified position"
320 | }
321 | else:
322 | return {
323 | "success": False,
324 | "message": "No track found",
325 | "errorDetails": "No track found near specified position"
326 | }
327 |
328 | except Exception as e:
329 | logger.error(f"Error deleting trace: {str(e)}")
330 | return {
331 | "success": False,
332 | "message": "Failed to delete trace",
333 | "errorDetails": str(e)
334 | }
335 |
336 | def get_nets_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
337 | """Get a list of all nets in the PCB"""
338 | try:
339 | if not self.board:
340 | return {
341 | "success": False,
342 | "message": "No board is loaded",
343 | "errorDetails": "Load or create a board first"
344 | }
345 |
346 | nets = []
347 | netinfo = self.board.GetNetInfo()
348 | for net_code in range(netinfo.GetNetCount()):
349 | net = netinfo.GetNetItem(net_code)
350 | if net:
351 | nets.append({
352 | "name": net.GetNetname(),
353 | "code": net.GetNetCode(),
354 | "class": net.GetClassName()
355 | })
356 |
357 | return {
358 | "success": True,
359 | "nets": nets
360 | }
361 |
362 | except Exception as e:
363 | logger.error(f"Error getting nets list: {str(e)}")
364 | return {
365 | "success": False,
366 | "message": "Failed to get nets list",
367 | "errorDetails": str(e)
368 | }
369 |
370 | def create_netclass(self, params: Dict[str, Any]) -> Dict[str, Any]:
371 | """Create a new net class with specified properties"""
372 | try:
373 | if not self.board:
374 | return {
375 | "success": False,
376 | "message": "No board is loaded",
377 | "errorDetails": "Load or create a board first"
378 | }
379 |
380 | name = params.get("name")
381 | clearance = params.get("clearance")
382 | track_width = params.get("trackWidth")
383 | via_diameter = params.get("viaDiameter")
384 | via_drill = params.get("viaDrill")
385 | uvia_diameter = params.get("uviaDiameter")
386 | uvia_drill = params.get("uviaDrill")
387 | diff_pair_width = params.get("diffPairWidth")
388 | diff_pair_gap = params.get("diffPairGap")
389 | nets = params.get("nets", [])
390 |
391 | if not name:
392 | return {
393 | "success": False,
394 | "message": "Missing netclass name",
395 | "errorDetails": "name parameter is required"
396 | }
397 |
398 | # Get net classes
399 | net_classes = self.board.GetNetClasses()
400 |
401 | # Create new net class if it doesn't exist
402 | if not net_classes.Find(name):
403 | netclass = pcbnew.NETCLASS(name)
404 | net_classes.Add(netclass)
405 | else:
406 | netclass = net_classes.Find(name)
407 |
408 | # Set properties
409 | scale = 1000000 # mm to nm
410 | if clearance is not None:
411 | netclass.SetClearance(int(clearance * scale))
412 | if track_width is not None:
413 | netclass.SetTrackWidth(int(track_width * scale))
414 | if via_diameter is not None:
415 | netclass.SetViaDiameter(int(via_diameter * scale))
416 | if via_drill is not None:
417 | netclass.SetViaDrill(int(via_drill * scale))
418 | if uvia_diameter is not None:
419 | netclass.SetMicroViaDiameter(int(uvia_diameter * scale))
420 | if uvia_drill is not None:
421 | netclass.SetMicroViaDrill(int(uvia_drill * scale))
422 | if diff_pair_width is not None:
423 | netclass.SetDiffPairWidth(int(diff_pair_width * scale))
424 | if diff_pair_gap is not None:
425 | netclass.SetDiffPairGap(int(diff_pair_gap * scale))
426 |
427 | # Add nets to net class
428 | netinfo = self.board.GetNetInfo()
429 | nets_map = netinfo.NetsByName()
430 | for net_name in nets:
431 | if nets_map.has_key(net_name):
432 | net = nets_map[net_name]
433 | net.SetClass(netclass)
434 |
435 | return {
436 | "success": True,
437 | "message": f"Created net class: {name}",
438 | "netClass": {
439 | "name": name,
440 | "clearance": netclass.GetClearance() / scale,
441 | "trackWidth": netclass.GetTrackWidth() / scale,
442 | "viaDiameter": netclass.GetViaDiameter() / scale,
443 | "viaDrill": netclass.GetViaDrill() / scale,
444 | "uviaDiameter": netclass.GetMicroViaDiameter() / scale,
445 | "uviaDrill": netclass.GetMicroViaDrill() / scale,
446 | "diffPairWidth": netclass.GetDiffPairWidth() / scale,
447 | "diffPairGap": netclass.GetDiffPairGap() / scale,
448 | "nets": nets
449 | }
450 | }
451 |
452 | except Exception as e:
453 | logger.error(f"Error creating net class: {str(e)}")
454 | return {
455 | "success": False,
456 | "message": "Failed to create net class",
457 | "errorDetails": str(e)
458 | }
459 |
460 | def add_copper_pour(self, params: Dict[str, Any]) -> Dict[str, Any]:
461 | """Add a copper pour (zone) to the PCB"""
462 | try:
463 | if not self.board:
464 | return {
465 | "success": False,
466 | "message": "No board is loaded",
467 | "errorDetails": "Load or create a board first"
468 | }
469 |
470 | layer = params.get("layer", "F.Cu")
471 | net = params.get("net")
472 | clearance = params.get("clearance")
473 | min_width = params.get("minWidth", 0.2)
474 | points = params.get("points", [])
475 | priority = params.get("priority", 0)
476 | fill_type = params.get("fillType", "solid") # solid or hatched
477 |
478 | if not points or len(points) < 3:
479 | return {
480 | "success": False,
481 | "message": "Missing points",
482 | "errorDetails": "At least 3 points are required for copper pour outline"
483 | }
484 |
485 | # Get layer ID
486 | layer_id = self.board.GetLayerID(layer)
487 | if layer_id < 0:
488 | return {
489 | "success": False,
490 | "message": "Invalid layer",
491 | "errorDetails": f"Layer '{layer}' does not exist"
492 | }
493 |
494 | # Create zone
495 | zone = pcbnew.ZONE(self.board)
496 | zone.SetLayer(layer_id)
497 |
498 | # Set net if provided
499 | if net:
500 | netinfo = self.board.GetNetInfo()
501 | nets_map = netinfo.NetsByName()
502 | if nets_map.has_key(net):
503 | net_obj = nets_map[net]
504 | zone.SetNet(net_obj)
505 |
506 | # Set zone properties
507 | scale = 1000000 # mm to nm
508 | zone.SetAssignedPriority(priority)
509 |
510 | if clearance is not None:
511 | zone.SetLocalClearance(int(clearance * scale))
512 |
513 | zone.SetMinThickness(int(min_width * scale))
514 |
515 | # Set fill type
516 | if fill_type == "hatched":
517 | zone.SetFillMode(pcbnew.ZONE_FILL_MODE_HATCH_PATTERN)
518 | else:
519 | zone.SetFillMode(pcbnew.ZONE_FILL_MODE_POLYGONS)
520 |
521 | # Create outline
522 | outline = zone.Outline()
523 | outline.NewOutline() # Create a new outline contour first
524 |
525 | # Add points to outline
526 | for point in points:
527 | scale = 1000000 if point.get("unit", "mm") == "mm" else 25400000
528 | x_nm = int(point["x"] * scale)
529 | y_nm = int(point["y"] * scale)
530 | outline.Append(pcbnew.VECTOR2I(x_nm, y_nm)) # Add point to outline
531 |
532 | # Add zone to board
533 | self.board.Add(zone)
534 |
535 | # Fill zone
536 | # Note: Zone filling can cause issues with SWIG API
537 | # Comment out for now - zones will be filled when board is saved/opened in KiCAD
538 | # filler = pcbnew.ZONE_FILLER(self.board)
539 | # filler.Fill(self.board.Zones())
540 |
541 | return {
542 | "success": True,
543 | "message": "Added copper pour",
544 | "pour": {
545 | "layer": layer,
546 | "net": net,
547 | "clearance": clearance,
548 | "minWidth": min_width,
549 | "priority": priority,
550 | "fillType": fill_type,
551 | "pointCount": len(points)
552 | }
553 | }
554 |
555 | except Exception as e:
556 | logger.error(f"Error adding copper pour: {str(e)}")
557 | return {
558 | "success": False,
559 | "message": "Failed to add copper pour",
560 | "errorDetails": str(e)
561 | }
562 |
563 | def route_differential_pair(self, params: Dict[str, Any]) -> Dict[str, Any]:
564 | """Route a differential pair between two sets of points or pads"""
565 | try:
566 | if not self.board:
567 | return {
568 | "success": False,
569 | "message": "No board is loaded",
570 | "errorDetails": "Load or create a board first"
571 | }
572 |
573 | start_pos = params.get("startPos")
574 | end_pos = params.get("endPos")
575 | net_pos = params.get("netPos")
576 | net_neg = params.get("netNeg")
577 | layer = params.get("layer", "F.Cu")
578 | width = params.get("width")
579 | gap = params.get("gap")
580 |
581 | if not start_pos or not end_pos or not net_pos or not net_neg:
582 | return {
583 | "success": False,
584 | "message": "Missing parameters",
585 | "errorDetails": "startPos, endPos, netPos, and netNeg are required"
586 | }
587 |
588 | # Get layer ID
589 | layer_id = self.board.GetLayerID(layer)
590 | if layer_id < 0:
591 | return {
592 | "success": False,
593 | "message": "Invalid layer",
594 | "errorDetails": f"Layer '{layer}' does not exist"
595 | }
596 |
597 | # Get nets
598 | netinfo = self.board.GetNetInfo()
599 | nets_map = netinfo.NetsByName()
600 |
601 | net_pos_obj = nets_map[net_pos] if nets_map.has_key(net_pos) else None
602 | net_neg_obj = nets_map[net_neg] if nets_map.has_key(net_neg) else None
603 |
604 | if not net_pos_obj or not net_neg_obj:
605 | return {
606 | "success": False,
607 | "message": "Nets not found",
608 | "errorDetails": "One or both nets specified for the differential pair do not exist"
609 | }
610 |
611 | # Get start and end points
612 | start_point = self._get_point(start_pos)
613 | end_point = self._get_point(end_pos)
614 |
615 | # Calculate offset vectors for the two traces
616 | # First, get the direction vector from start to end
617 | dx = end_point.x - start_point.x
618 | dy = end_point.y - start_point.y
619 | length = math.sqrt(dx * dx + dy * dy)
620 |
621 | if length <= 0:
622 | return {
623 | "success": False,
624 | "message": "Invalid points",
625 | "errorDetails": "Start and end points must be different"
626 | }
627 |
628 | # Normalize direction vector
629 | dx /= length
630 | dy /= length
631 |
632 | # Get perpendicular vector
633 | px = -dy
634 | py = dx
635 |
636 | # Set default gap if not provided
637 | if gap is None:
638 | gap = 0.2 # mm
639 |
640 | # Convert to nm
641 | gap_nm = int(gap * 1000000)
642 |
643 | # Calculate offsets
644 | offset_x = int(px * gap_nm / 2)
645 | offset_y = int(py * gap_nm / 2)
646 |
647 | # Create positive and negative trace points
648 | pos_start = pcbnew.VECTOR2I(int(start_point.x + offset_x), int(start_point.y + offset_y))
649 | pos_end = pcbnew.VECTOR2I(int(end_point.x + offset_x), int(end_point.y + offset_y))
650 | neg_start = pcbnew.VECTOR2I(int(start_point.x - offset_x), int(start_point.y - offset_y))
651 | neg_end = pcbnew.VECTOR2I(int(end_point.x - offset_x), int(end_point.y - offset_y))
652 |
653 | # Create positive trace
654 | pos_track = pcbnew.PCB_TRACK(self.board)
655 | pos_track.SetStart(pos_start)
656 | pos_track.SetEnd(pos_end)
657 | pos_track.SetLayer(layer_id)
658 | pos_track.SetNet(net_pos_obj)
659 |
660 | # Create negative trace
661 | neg_track = pcbnew.PCB_TRACK(self.board)
662 | neg_track.SetStart(neg_start)
663 | neg_track.SetEnd(neg_end)
664 | neg_track.SetLayer(layer_id)
665 | neg_track.SetNet(net_neg_obj)
666 |
667 | # Set width
668 | if width:
669 | trace_width_nm = int(width * 1000000)
670 | pos_track.SetWidth(trace_width_nm)
671 | neg_track.SetWidth(trace_width_nm)
672 | else:
673 | # Get default width from design rules or net class
674 | trace_width = self.board.GetDesignSettings().GetCurrentTrackWidth()
675 | pos_track.SetWidth(trace_width)
676 | neg_track.SetWidth(trace_width)
677 |
678 | # Add tracks to board
679 | self.board.Add(pos_track)
680 | self.board.Add(neg_track)
681 |
682 | return {
683 | "success": True,
684 | "message": "Added differential pair traces",
685 | "diffPair": {
686 | "posNet": net_pos,
687 | "negNet": net_neg,
688 | "layer": layer,
689 | "width": pos_track.GetWidth() / 1000000,
690 | "gap": gap,
691 | "length": length / 1000000
692 | }
693 | }
694 |
695 | except Exception as e:
696 | logger.error(f"Error routing differential pair: {str(e)}")
697 | return {
698 | "success": False,
699 | "message": "Failed to route differential pair",
700 | "errorDetails": str(e)
701 | }
702 |
703 | def _get_point(self, point_spec: Dict[str, Any]) -> pcbnew.VECTOR2I:
704 | """Convert point specification to KiCAD point"""
705 | if "x" in point_spec and "y" in point_spec:
706 | scale = 1000000 if point_spec.get("unit", "mm") == "mm" else 25400000
707 | x_nm = int(point_spec["x"] * scale)
708 | y_nm = int(point_spec["y"] * scale)
709 | return pcbnew.VECTOR2I(x_nm, y_nm)
710 | elif "pad" in point_spec and "componentRef" in point_spec:
711 | module = self.board.FindFootprintByReference(point_spec["componentRef"])
712 | if module:
713 | pad = module.FindPadByName(point_spec["pad"])
714 | if pad:
715 | return pad.GetPosition()
716 | raise ValueError("Invalid point specification")
717 |
718 | def _point_to_track_distance(self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK) -> float:
719 | """Calculate distance from point to track segment"""
720 | start = track.GetStart()
721 | end = track.GetEnd()
722 |
723 | # Vector from start to end
724 | v = pcbnew.VECTOR2I(end.x - start.x, end.y - start.y)
725 | # Vector from start to point
726 | w = pcbnew.VECTOR2I(point.x - start.x, point.y - start.y)
727 |
728 | # Length of track squared
729 | c1 = v.x * v.x + v.y * v.y
730 | if c1 == 0:
731 | return self._point_distance(point, start)
732 |
733 | # Projection coefficient
734 | c2 = float(w.x * v.x + w.y * v.y) / c1
735 |
736 | if c2 < 0:
737 | return self._point_distance(point, start)
738 | elif c2 > 1:
739 | return self._point_distance(point, end)
740 |
741 | # Point on line
742 | proj = pcbnew.VECTOR2I(
743 | int(start.x + c2 * v.x),
744 | int(start.y + c2 * v.y)
745 | )
746 | return self._point_distance(point, proj)
747 |
748 | def _point_distance(self, p1: pcbnew.VECTOR2I, p2: pcbnew.VECTOR2I) -> float:
749 | """Calculate distance between two points"""
750 | dx = p1.x - p2.x
751 | dy = p1.y - p2.y
752 | return (dx * dx + dy * dy) ** 0.5
753 |
```