@hybridlabor-api/bdb-hardware-pcb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -0
- package/config/mcp/antigravity.json +22 -0
- package/config/mcp/claude.json +22 -0
- package/config/mcp/codex.toml +25 -0
- package/docs/adr/ADR-001-KICAD-OPENSCAD-MCP-STRATEGY.md +334 -0
- package/docs/review_documentation.md +121 -0
- package/installer.js +358 -0
- package/mcp_servers/kicad-mcp-server/.env.example +22 -0
- package/mcp_servers/kicad-mcp-server/.github/workflows/ci.yml +36 -0
- package/mcp_servers/kicad-mcp-server/CLAUDE.md +487 -0
- package/mcp_servers/kicad-mcp-server/README.md +316 -0
- package/mcp_servers/kicad-mcp-server/docs/DEVICE_TREE.md +416 -0
- package/mcp_servers/kicad-mcp-server/docs/INSTALLATION.md +332 -0
- package/mcp_servers/kicad-mcp-server/docs/PIN_ANALYSIS.md +332 -0
- package/mcp_servers/kicad-mcp-server/docs/README.md +240 -0
- package/mcp_servers/kicad-mcp-server/docs/TESTING.md +613 -0
- package/mcp_servers/kicad-mcp-server/docs/VALIDATION.md +268 -0
- package/mcp_servers/kicad-mcp-server/pyproject.toml +96 -0
- package/mcp_servers/kicad-mcp-server/requirements-dev.txt +16 -0
- package/mcp_servers/kicad-mcp-server/requirements-test.txt +24 -0
- package/mcp_servers/kicad-mcp-server/requirements.txt +13 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__init__.py +3 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__main__.py +17 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/config.py +46 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/types.py +87 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/netlist_parser.py +234 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser.py +375 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser_kicad.py +327 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/schematic_parser.py +902 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/server.py +71 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/arduino/connectivity_test.cpp.j2 +189 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/atmega.dts.j2 +77 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/esp32.dts.j2 +77 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/nrf52.dts.j2 +77 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/stm32f4.dts.j2 +89 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/esp_idf/test_suite.c.j2 +340 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/pytest/test_connectivity.py.j2 +147 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/st_hal/hal_test.c.j2 +313 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_gpio_test.py.j2 +99 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_i2c_test.py.j2 +117 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_pinmux_test.py.j2 +43 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_spi_test.py.j2 +94 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_gpio_test.c.j2 +113 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_i2c_test.c.j2 +101 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_spi_test.c.j2 +94 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/unittest/test_schematic.py.j2 +172 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/__init__.py +36 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/device_tree.py +1187 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/hierarchical_analysis.py +211 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/netlist.py +320 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/parts_registry.py +142 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb.py +955 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb_layout.py +308 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pin_analysis.py +765 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/project.py +196 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic.py +319 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_editor.py +674 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_search.py +158 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/validation.py +866 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/visualization.py +225 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/file_handlers.py +65 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_cli.py +103 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_version.py +103 -0
- package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/parts_registry.py +197 -0
- package/mcp_servers/kicad-mcp-server/tests/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/tests/examples/ESP32S3_TEST.md +219 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/README.md +65 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/example_pcb.kicad_pcb +177 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/example_schematic.kicad_sch +145 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/child.kicad_sch +24 -0
- package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/root.kicad_sch +38 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/__init__.py +1 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_hierarchical_labels.py +195 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_kicad_cli.py +116 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_netlist_cache_path.py +29 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic.py +189 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic_hierarchy.py +69 -0
- package/mcp_servers/kicad-mcp-server/tests/test_tools/test_visualization.py +136 -0
- package/mcp_servers/kicad-mcp-server/uv.lock +2873 -0
- package/mcp_servers/openscad-mcp-server/.dockerignore +9 -0
- package/mcp_servers/openscad-mcp-server/.github/workflows/test.yml +40 -0
- package/mcp_servers/openscad-mcp-server/Dockerfile +29 -0
- package/mcp_servers/openscad-mcp-server/LICENSE +21 -0
- package/mcp_servers/openscad-mcp-server/README.md +154 -0
- package/mcp_servers/openscad-mcp-server/docs/audit.md +56 -0
- package/mcp_servers/openscad-mcp-server/docs/docker.md +66 -0
- package/mcp_servers/openscad-mcp-server/docs/issue-followup.md +19 -0
- package/mcp_servers/openscad-mcp-server/docs/jetson.md +17 -0
- package/mcp_servers/openscad-mcp-server/glama.json +4 -0
- package/mcp_servers/openscad-mcp-server/legacy/README.md +15 -0
- package/mcp_servers/openscad-mcp-server/legacy/README.original.md +294 -0
- package/mcp_servers/openscad-mcp-server/legacy/implementation_plan.md +100 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/download_sam2_checkpoint.py +115 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/src/ai/sam_segmentation.py +209 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/src/models/threestudio_generator.py +231 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/src/workflow/image_to_model_pipeline.py +260 -0
- package/mcp_servers/openscad-mcp-server/legacy/old/test_sam2_segmentation.py +96 -0
- package/mcp_servers/openscad-mcp-server/legacy/requirements.txt +57 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/README.md +39 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/ai-driven-code-generation.md +122 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/export-formats.md +76 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/ai/ai_service.py.md +51 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/main.py.md +63 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/models/code_generator.py.md +63 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/nlp/parameter_extractor.py.md +63 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/ai/natural-language-processing.md +78 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/nlp/parameter-extraction.md +173 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/export-formats.md +91 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/openscad-basics.md +66 -0
- package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/primitive-testing.md +79 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/ai/ai_service.py +257 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/ai/gemini_api.py +161 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/ai/venice_api.py +203 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/config.py +121 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/main.py +1456 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/main.py.new +404 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/main_remote.py +401 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/code_generator.py +321 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/cuda_mvs.py +209 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/models/scad_templates/basic_shapes.scad +144 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/nlp/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/nlp/parameter_extractor.py +388 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/wrapper.py +418 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/__init__.py +1 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/printer_discovery.py +471 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/connection_manager.py +537 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_client.py +435 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_server.py +787 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/remote/error_handling.py +415 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/testing/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/testing/primitive_tester.py +203 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/testing/test_primitives.py +98 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/__init__.py +1 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/cad_exporter.py +241 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/format_validator.py +206 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_exporter.py +140 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_repair.py +91 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_validator.py +123 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/__init__.py +0 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/headless_renderer.py +52 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/renderer.py +177 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/visualization/web_interface.py +639 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/workflow/image_approval.py +148 -0
- package/mcp_servers/openscad-mcp-server/legacy/src/workflow/multi_view_to_model_pipeline.py +338 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_complete_workflow.py +374 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_cuda_mvs.py +191 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_gemini_api.py +168 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_image_approval.py +192 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_image_approval_workflow.py +251 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_image_to_model_pipeline.py +145 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_model_selection.py +41 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_multi_view_pipeline.py +290 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_primitives.sh +13 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_rabbit_direct.py +71 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_remote_cuda_mvs.py +283 -0
- package/mcp_servers/openscad-mcp-server/legacy/test_venice_example.py +69 -0
- package/mcp_servers/openscad-mcp-server/pyproject.toml +33 -0
- package/mcp_servers/openscad-mcp-server/requirements.txt +2 -0
- package/mcp_servers/openscad-mcp-server/scad/simple_cube.scad +2 -0
- package/mcp_servers/openscad-mcp-server/scripts/test_docker.py +220 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__init__.py +3 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__main__.py +3 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/engine.py +94 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/geometry.py +242 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/server.py +245 -0
- package/mcp_servers/openscad-mcp-server/src/openscad_mcp/service.py +190 -0
- package/mcp_servers/openscad-mcp-server/tests/conftest.py +17 -0
- package/mcp_servers/openscad-mcp-server/tests/test_engine.py +37 -0
- package/mcp_servers/openscad-mcp-server/tests/test_geometry.py +106 -0
- package/mcp_servers/openscad-mcp-server/tests/test_integration.py +172 -0
- package/mcp_servers/openscad-mcp-server/tests/test_transports.py +314 -0
- package/mcp_servers/openscad-mcp-server/uv.lock +1123 -0
- package/package.json +44 -0
- package/scripts/install_mcps.sh +86 -0
- package/scripts/openscad_wrapper.sh +62 -0
- package/scripts/run_kicad_mcp.sh +25 -0
- package/scripts/run_openscad_mcp.sh +41 -0
- package/scripts/test_mcp_connection.py +626 -0
- package/scripts/test_mcp_connection.sh +168 -0
- package/skills/code-first-hardware-design/SKILL.md +260 -0
- package/skills/pcb-constraint-definition/SKILL.md +236 -0
- package/skills/pcb-layout-routing-automation/SKILL.md +153 -0
- package/skills/pcb-validation-dfm-signoff/SKILL.md +194 -0
- package/skills/schematic-datasheet-analysis/SKILL.md +202 -0
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
"""PCB analysis tools for KiCad MCP Server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..server import mcp
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _get_parser(file_path: str):
|
|
9
|
+
"""Try pcbnew parser first, fall back to text parser."""
|
|
10
|
+
try:
|
|
11
|
+
from ..parsers.pcb_parser_kicad import PCBParserKiCad
|
|
12
|
+
return PCBParserKiCad(file_path)
|
|
13
|
+
except (ImportError, Exception):
|
|
14
|
+
from ..parsers.pcb_parser import PCBParser
|
|
15
|
+
return PCBParser(file_path)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _is_pcbnew(parser) -> bool:
|
|
19
|
+
"""Check if parser is the pcbnew-based one."""
|
|
20
|
+
from ..parsers.pcb_parser_kicad import PCBParserKiCad
|
|
21
|
+
return isinstance(parser, PCBParserKiCad)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool()
|
|
25
|
+
async def list_pcb_footprints(
|
|
26
|
+
file_path: str,
|
|
27
|
+
filter_layer: str | None = None,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""List all footprints in a KiCad PCB file.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
file_path: Path to .kicad_pcb file
|
|
33
|
+
filter_layer: Optional filter by layer (e.g., 'F.Cu', 'B.Cu')
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Formatted list of footprints
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
parser = _get_parser(file_path)
|
|
40
|
+
|
|
41
|
+
if _is_pcbnew(parser):
|
|
42
|
+
footprints = parser.get_footprints()
|
|
43
|
+
if filter_layer:
|
|
44
|
+
footprints = [f for f in footprints if f.layer == filter_layer]
|
|
45
|
+
|
|
46
|
+
if not footprints:
|
|
47
|
+
return "No footprints found."
|
|
48
|
+
|
|
49
|
+
lines = [
|
|
50
|
+
f"# Footprints in {file_path}",
|
|
51
|
+
f"Total: {len(footprints)} footprint(s)",
|
|
52
|
+
"",
|
|
53
|
+
"| Reference | Value | Library | Layer | Position | Rotation | Pads |",
|
|
54
|
+
"|-----------|-------|---------|-------|----------|----------|------|",
|
|
55
|
+
]
|
|
56
|
+
for fp in footprints:
|
|
57
|
+
pos_str = f"({fp.position[0]:.2f}, {fp.position[1]:.2f})"
|
|
58
|
+
lines.append(
|
|
59
|
+
f"| {fp.reference} | {fp.value} | {fp.library} | {fp.layer} | {pos_str} | {fp.rotation:.1f}° | {fp.pads_count} |"
|
|
60
|
+
)
|
|
61
|
+
return "\n".join(lines)
|
|
62
|
+
else:
|
|
63
|
+
footprints = parser.get_footprints()
|
|
64
|
+
if filter_layer:
|
|
65
|
+
footprints = [f for f in footprints if f.layer == filter_layer]
|
|
66
|
+
|
|
67
|
+
if not footprints:
|
|
68
|
+
return "No footprints found."
|
|
69
|
+
|
|
70
|
+
lines = [
|
|
71
|
+
f"# Footprints in {file_path}",
|
|
72
|
+
f"Total: {len(footprints)} footprint(s)",
|
|
73
|
+
"",
|
|
74
|
+
"| Reference | Value | Footprint | Layer | Position | Rotation | Pads |",
|
|
75
|
+
"|-----------|-------|-----------|-------|----------|----------|------|",
|
|
76
|
+
]
|
|
77
|
+
for fp in footprints:
|
|
78
|
+
fp_name = fp.footprint_id.split(":")[-1] if ":" in fp.footprint_id else fp.footprint_id
|
|
79
|
+
pos_str = f"({fp.position[0]:.2f}, {fp.position[1]:.2f})"
|
|
80
|
+
lines.append(
|
|
81
|
+
f"| {fp.reference} | {fp.value} | {fp_name} | {fp.layer} | {pos_str} | {fp.rotation:.1f}° | {fp.pad_count} |"
|
|
82
|
+
)
|
|
83
|
+
return "\n".join(lines)
|
|
84
|
+
|
|
85
|
+
except FileNotFoundError as e:
|
|
86
|
+
return f"Error: {e}"
|
|
87
|
+
except Exception as e:
|
|
88
|
+
return f"Error parsing PCB: {e}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@mcp.tool()
|
|
92
|
+
async def get_pcb_statistics(file_path: str) -> str:
|
|
93
|
+
"""Get statistics about a KiCad PCB design.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
file_path: Path to .kicad_pcb file
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
PCB statistics and metrics
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
parser = _get_parser(file_path)
|
|
103
|
+
|
|
104
|
+
if _is_pcbnew(parser):
|
|
105
|
+
info = parser.get_board_info()
|
|
106
|
+
rules = parser.get_design_rules()
|
|
107
|
+
|
|
108
|
+
lines = [
|
|
109
|
+
f"# PCB Statistics: {file_path}",
|
|
110
|
+
"",
|
|
111
|
+
"## Board Information",
|
|
112
|
+
f"**Dimensions:** {info['board_width_mm']:.2f} x {info['board_height_mm']:.2f} mm",
|
|
113
|
+
f"**Copper Layers:** {rules['copper_layers']}",
|
|
114
|
+
f"**Board Thickness:** {rules['board_thickness_mm']:.3f} mm",
|
|
115
|
+
"",
|
|
116
|
+
"## Elements",
|
|
117
|
+
f"**Footprints:** {info['footprints_count']}",
|
|
118
|
+
f"**Track Segments:** {info['tracks_count']}",
|
|
119
|
+
f"**Vias:** {info['vias_count']}",
|
|
120
|
+
f"**Copper Zones:** {info['zones_count']}",
|
|
121
|
+
f"**Nets:** {info['nets_count']}",
|
|
122
|
+
"",
|
|
123
|
+
"## Design Rules",
|
|
124
|
+
f"**Smallest Clearance:** {rules['smallest_clearance_mm']:.4f} mm",
|
|
125
|
+
f"**Default Track Width:** {rules['current_track_width_mm']:.4f} mm",
|
|
126
|
+
f"**Default Via Size/Drill:** {rules['current_via_size_mm']:.4f} / {rules['current_via_drill_mm']:.4f} mm",
|
|
127
|
+
f"**Diff Pair Width/Gap:** {rules['current_diff_pair_width_mm']:.4f} / {rules['current_diff_pair_gap_mm']:.4f} mm",
|
|
128
|
+
]
|
|
129
|
+
return "\n".join(lines)
|
|
130
|
+
else:
|
|
131
|
+
stats = parser.get_statistics()
|
|
132
|
+
lines = [
|
|
133
|
+
f"# PCB Statistics: {file_path}",
|
|
134
|
+
"",
|
|
135
|
+
"## Board Information",
|
|
136
|
+
f"**Dimensions:** {stats['board_width']:.2f} x {stats['board_height']:.2f} mm",
|
|
137
|
+
f"**Layers:** {stats['layers']}",
|
|
138
|
+
f"**Thickness:** {stats['thickness']:.2f} mm",
|
|
139
|
+
"",
|
|
140
|
+
"## Elements",
|
|
141
|
+
f"**Footprints:** {stats['total_footprints']}",
|
|
142
|
+
f"**Total Pads:** {stats['total_pads']}",
|
|
143
|
+
f"**Track Segments:** {stats['total_tracks']}",
|
|
144
|
+
f"**Vias:** {stats['total_vias']}",
|
|
145
|
+
f"**Copper Zones:** {stats['total_zones']}",
|
|
146
|
+
"",
|
|
147
|
+
"## Averages",
|
|
148
|
+
f"**Pads per Footprint:** {stats['total_pads'] / max(stats['total_footprints'], 1):.1f}",
|
|
149
|
+
]
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
except FileNotFoundError as e:
|
|
153
|
+
return f"Error: {e}"
|
|
154
|
+
except Exception as e:
|
|
155
|
+
return f"Error parsing PCB: {e}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@mcp.tool()
|
|
159
|
+
async def analyze_pcb_nets(file_path: str) -> str:
|
|
160
|
+
"""Analyze nets in a KiCad PCB file.
|
|
161
|
+
|
|
162
|
+
Provides track width distribution, via distribution, per-net track
|
|
163
|
+
length ranking, layer coverage, and design rule comparison.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
file_path: Path to .kicad_pcb file
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Net information and routing analysis from PCB
|
|
170
|
+
"""
|
|
171
|
+
try:
|
|
172
|
+
parser = _get_parser(file_path)
|
|
173
|
+
|
|
174
|
+
if not _is_pcbnew(parser):
|
|
175
|
+
data = parser._parse_file()
|
|
176
|
+
tracks = data["tracks"]
|
|
177
|
+
vias = data["vias"]
|
|
178
|
+
zones = data["zones"]
|
|
179
|
+
|
|
180
|
+
lines = [
|
|
181
|
+
f"# PCB Routing Analysis: {file_path}",
|
|
182
|
+
"",
|
|
183
|
+
"⚠️ pcbnew not available — text-based analysis (limited precision)",
|
|
184
|
+
"",
|
|
185
|
+
"## Overview",
|
|
186
|
+
f"**Track Segments:** {len(tracks)}",
|
|
187
|
+
f"**Vias:** {len(vias)}",
|
|
188
|
+
f"**Copper Zones:** {len(zones)}",
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
# Track width distribution
|
|
192
|
+
width_counts: dict[str, int] = {}
|
|
193
|
+
for t in tracks:
|
|
194
|
+
key = f"{t['width']:.3f}mm"
|
|
195
|
+
width_counts[key] = width_counts.get(key, 0) + 1
|
|
196
|
+
if width_counts:
|
|
197
|
+
lines += [
|
|
198
|
+
"",
|
|
199
|
+
"## Track Width Distribution",
|
|
200
|
+
"| Width | Count |",
|
|
201
|
+
"|-------|-------|",
|
|
202
|
+
]
|
|
203
|
+
for w, c in sorted(width_counts.items(), key=lambda x: -x[1]):
|
|
204
|
+
lines.append(f"| {w} | {c}x |")
|
|
205
|
+
|
|
206
|
+
# Via drill distribution
|
|
207
|
+
drill_counts: dict[str, int] = {}
|
|
208
|
+
for v in vias:
|
|
209
|
+
key = f"{v['drill']:.3f}mm"
|
|
210
|
+
drill_counts[key] = drill_counts.get(key, 0) + 1
|
|
211
|
+
if drill_counts:
|
|
212
|
+
lines += [
|
|
213
|
+
"",
|
|
214
|
+
"## Via Drill Distribution",
|
|
215
|
+
f"**Total vias:** {len(vias)}",
|
|
216
|
+
"",
|
|
217
|
+
"| Drill Size | Count |",
|
|
218
|
+
"|------------|-------|",
|
|
219
|
+
]
|
|
220
|
+
for d, c in sorted(drill_counts.items(), key=lambda x: -x[1]):
|
|
221
|
+
lines.append(f"| {d} | {c}x |")
|
|
222
|
+
|
|
223
|
+
# Per-net track stats
|
|
224
|
+
net_stats: dict[str, dict] = {}
|
|
225
|
+
for t in tracks:
|
|
226
|
+
net = t.get("net", "(unnamed)")
|
|
227
|
+
if net not in net_stats:
|
|
228
|
+
net_stats[net] = {"total_length": 0.0, "segment_count": 0, "widths": [], "layers": set()}
|
|
229
|
+
s = net_stats[net]
|
|
230
|
+
s["total_length"] += t.get("length", 0)
|
|
231
|
+
s["segment_count"] += 1
|
|
232
|
+
s["widths"].append(t["width"])
|
|
233
|
+
s["layers"].add(t["layer"])
|
|
234
|
+
|
|
235
|
+
top_nets = sorted(net_stats.items(), key=lambda x: -x[1]["total_length"])[:20]
|
|
236
|
+
if top_nets:
|
|
237
|
+
lines += [
|
|
238
|
+
"",
|
|
239
|
+
"## Top 20 Nets by Track Length",
|
|
240
|
+
"| Net | Length (mm) | Segments | Widths | Layers |",
|
|
241
|
+
"|-----|-------------|----------|--------|--------|",
|
|
242
|
+
]
|
|
243
|
+
for name, s in top_nets:
|
|
244
|
+
unique_widths = sorted({f"{w:.3f}" for w in s["widths"]})
|
|
245
|
+
widths_str = ", ".join(unique_widths)
|
|
246
|
+
layers_str = ", ".join(sorted(s["layers"]))
|
|
247
|
+
lines.append(
|
|
248
|
+
f"| {name} | {s['total_length']:.2f} | {s['segment_count']} | {widths_str} | {layers_str} |"
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return "\n".join(lines)
|
|
252
|
+
|
|
253
|
+
net_stats = parser.get_net_track_stats()
|
|
254
|
+
via_stats = parser.get_via_stats()
|
|
255
|
+
rules = parser.get_design_rules()
|
|
256
|
+
zones = parser.get_zones()
|
|
257
|
+
info = parser.get_board_info()
|
|
258
|
+
|
|
259
|
+
lines = [
|
|
260
|
+
f"# PCB Routing Analysis: {file_path}",
|
|
261
|
+
"",
|
|
262
|
+
"## Overview",
|
|
263
|
+
f"**Board:** {info['board_width_mm']:.2f} x {info['board_height_mm']:.2f} mm, "
|
|
264
|
+
f"{rules['copper_layers']} copper layers",
|
|
265
|
+
f"**Tracks:** {info['tracks_count']} segments across {len(net_stats)} nets",
|
|
266
|
+
f"**Vias:** {info['vias_count']}",
|
|
267
|
+
f"**Zones:** {len(zones)} copper zones",
|
|
268
|
+
]
|
|
269
|
+
|
|
270
|
+
# Track width distribution
|
|
271
|
+
width_counts: dict[str, int] = {}
|
|
272
|
+
for s in net_stats.values():
|
|
273
|
+
for w in s["widths"]:
|
|
274
|
+
key = f"{w:.3f}mm"
|
|
275
|
+
width_counts[key] = width_counts.get(key, 0) + 1
|
|
276
|
+
|
|
277
|
+
if width_counts:
|
|
278
|
+
lines += [
|
|
279
|
+
"",
|
|
280
|
+
"## Track Width Distribution",
|
|
281
|
+
"| Width | Count |",
|
|
282
|
+
"|-------|-------|",
|
|
283
|
+
]
|
|
284
|
+
for w, c in sorted(width_counts.items(), key=lambda x: -x[1]):
|
|
285
|
+
lines.append(f"| {w} | {c}x |")
|
|
286
|
+
|
|
287
|
+
# Via drill distribution
|
|
288
|
+
if via_stats["count"] > 0:
|
|
289
|
+
lines += [
|
|
290
|
+
"",
|
|
291
|
+
"## Via Drill Distribution",
|
|
292
|
+
f"**Total vias:** {via_stats['count']}",
|
|
293
|
+
"",
|
|
294
|
+
"| Drill Size | Count |",
|
|
295
|
+
"|------------|-------|",
|
|
296
|
+
]
|
|
297
|
+
for drill, count in via_stats["drill_sizes"].items():
|
|
298
|
+
lines.append(f"| {drill} | {count}x |")
|
|
299
|
+
|
|
300
|
+
lines += ["", "### Via Layer Spans"]
|
|
301
|
+
for span, count in via_stats["layer_spans"].items():
|
|
302
|
+
lines.append(f"- {span}: {count}x")
|
|
303
|
+
|
|
304
|
+
# Layer coverage — count each track on its own layer. Previously this
|
|
305
|
+
# added each net's TOTAL segment count to EVERY layer the net touches,
|
|
306
|
+
# inflating multi-layer net totals by the layer fanout.
|
|
307
|
+
layer_counts: dict[str, int] = {}
|
|
308
|
+
for t in parser.get_tracks():
|
|
309
|
+
layer_counts[t.layer] = layer_counts.get(t.layer, 0) + 1
|
|
310
|
+
|
|
311
|
+
if layer_counts:
|
|
312
|
+
lines += [
|
|
313
|
+
"",
|
|
314
|
+
"## Track Segments by Layer",
|
|
315
|
+
"| Layer | Segments |",
|
|
316
|
+
"|-------|----------|",
|
|
317
|
+
]
|
|
318
|
+
for layer, count in sorted(layer_counts.items(), key=lambda x: -x[1]):
|
|
319
|
+
lines.append(f"| {layer} | {count} |")
|
|
320
|
+
|
|
321
|
+
# Top nets by total track length
|
|
322
|
+
top_nets = sorted(net_stats.items(), key=lambda x: -x[1]["total_length"])[:20]
|
|
323
|
+
if top_nets:
|
|
324
|
+
lines += [
|
|
325
|
+
"",
|
|
326
|
+
"## Top 20 Nets by Track Length",
|
|
327
|
+
"| Net | Length (mm) | Segments | Widths | Layers |",
|
|
328
|
+
"|-----|-------------|----------|--------|--------|",
|
|
329
|
+
]
|
|
330
|
+
for name, s in top_nets:
|
|
331
|
+
unique_widths = sorted({f"{w:.3f}" for w in s["widths"]})
|
|
332
|
+
widths_str = ", ".join(unique_widths)
|
|
333
|
+
layers_str = ", ".join(s["layers"])
|
|
334
|
+
lines.append(
|
|
335
|
+
f"| {name} | {s['total_length']:.2f} | {s['segment_count']} | {widths_str} | {layers_str} |"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# Design rule comparison
|
|
339
|
+
if net_stats:
|
|
340
|
+
all_widths = [w for s in net_stats.values() for w in s["widths"]]
|
|
341
|
+
min_width = min(all_widths) if all_widths else 0
|
|
342
|
+
rule_width = rules["current_track_width_mm"]
|
|
343
|
+
lines += [
|
|
344
|
+
"",
|
|
345
|
+
"## Design Rule Comparison",
|
|
346
|
+
f"**Min track width in design:** {min_width:.4f} mm",
|
|
347
|
+
f"**Default track width (design rules):** {rule_width:.4f} mm",
|
|
348
|
+
f"**Smallest clearance:** {rules['smallest_clearance_mm']:.4f} mm",
|
|
349
|
+
]
|
|
350
|
+
if min_width < rule_width * 0.5:
|
|
351
|
+
lines.append(f"⚠️ Some tracks are significantly narrower than the default ({min_width:.4f} vs {rule_width:.4f} mm)")
|
|
352
|
+
|
|
353
|
+
return "\n".join(lines)
|
|
354
|
+
|
|
355
|
+
except FileNotFoundError as e:
|
|
356
|
+
return f"Error: {e}"
|
|
357
|
+
except Exception as e:
|
|
358
|
+
return f"Error parsing PCB: {e}"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
@mcp.tool()
|
|
362
|
+
async def find_tracks_by_net(file_path: str, net_name: str) -> str:
|
|
363
|
+
"""Find track segments belonging to a specific net.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
file_path: Path to .kicad_pcb file
|
|
367
|
+
net_name: Name of the net to search for
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Track information for the specified net with length analysis
|
|
371
|
+
"""
|
|
372
|
+
try:
|
|
373
|
+
parser = _get_parser(file_path)
|
|
374
|
+
|
|
375
|
+
if not _is_pcbnew(parser):
|
|
376
|
+
# Text parser fallback — tracks have net names from KiCad 10 format
|
|
377
|
+
data = parser._parse_file()
|
|
378
|
+
matched_tracks = [t for t in data["tracks"] if t.get("net") == net_name]
|
|
379
|
+
matched_vias = [v for v in data["vias"] if v.get("net") == net_name]
|
|
380
|
+
matched_zones = [z for z in data["zones"] if z.get("net_name") == net_name]
|
|
381
|
+
|
|
382
|
+
if not matched_tracks and not matched_vias:
|
|
383
|
+
# Try case-insensitive partial match
|
|
384
|
+
all_nets = sorted({
|
|
385
|
+
t.get("net", "") for t in data["tracks"] if t.get("net")
|
|
386
|
+
})
|
|
387
|
+
suggestions = [n for n in all_nets if net_name.lower() in n.lower()]
|
|
388
|
+
if suggestions:
|
|
389
|
+
return (
|
|
390
|
+
f"# Tracks for '{net_name}'\n\n"
|
|
391
|
+
"Exact match not found. Similar nets:\n"
|
|
392
|
+
+ "\n".join(f"- `{n}` ({sum(1 for t in data['tracks'] if t.get('net') == n)} segments)" for n in suggestions)
|
|
393
|
+
)
|
|
394
|
+
return f"No tracks found for net '{net_name}'."
|
|
395
|
+
|
|
396
|
+
total_length = sum(t.get("length", 0) for t in matched_tracks)
|
|
397
|
+
widths = [t["width"] for t in matched_tracks]
|
|
398
|
+
layers = sorted({t["layer"] for t in matched_tracks})
|
|
399
|
+
|
|
400
|
+
lines = [
|
|
401
|
+
f"# Track Analysis: {net_name}",
|
|
402
|
+
"",
|
|
403
|
+
"⚠️ pcbnew not available — text-based analysis (lengths are Euclidean approximations)",
|
|
404
|
+
"",
|
|
405
|
+
f"**Track Segments:** {len(matched_tracks)}",
|
|
406
|
+
f"**Vias:** {len(matched_vias)}",
|
|
407
|
+
f"**Copper Zones:** {len(matched_zones)}",
|
|
408
|
+
f"**Total Track Length:** {total_length:.3f} mm (Euclidean approximation)",
|
|
409
|
+
f"**Layers Used:** {', '.join(layers) if layers else 'N/A'}",
|
|
410
|
+
]
|
|
411
|
+
|
|
412
|
+
if widths:
|
|
413
|
+
unique_widths = sorted({f"{w:.4f}" for w in widths})
|
|
414
|
+
lines.append(f"**Track Widths:** {', '.join(unique_widths)} mm")
|
|
415
|
+
|
|
416
|
+
if matched_tracks:
|
|
417
|
+
lines += [
|
|
418
|
+
"",
|
|
419
|
+
"## Track Segments",
|
|
420
|
+
"| # | Start | End | Width | Layer | Length |",
|
|
421
|
+
"|---|-------|-----|-------|-------|--------|",
|
|
422
|
+
]
|
|
423
|
+
for i, t in enumerate(matched_tracks[:50], 1):
|
|
424
|
+
lines.append(
|
|
425
|
+
f"| {i} | ({t['start']['x']:.2f}, {t['start']['y']:.2f}) | "
|
|
426
|
+
f"({t['end']['x']:.2f}, {t['end']['y']:.2f}) | {t['width']:.4f} | "
|
|
427
|
+
f"{t['layer']} | {t.get('length', 0):.3f} |"
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
if matched_vias:
|
|
431
|
+
lines += [
|
|
432
|
+
"",
|
|
433
|
+
"## Vias",
|
|
434
|
+
"| # | Position | Drill | Layers |",
|
|
435
|
+
"|---|----------|-------|--------|",
|
|
436
|
+
]
|
|
437
|
+
for i, v in enumerate(matched_vias[:20], 1):
|
|
438
|
+
span = v.get("top_layer", "") + " -> " + v.get("bottom_layer", "") if v.get("top_layer") else v.get("layers", "")
|
|
439
|
+
lines.append(
|
|
440
|
+
f"| {i} | ({v['at']['x']:.2f}, {v['at']['y']:.2f}) | "
|
|
441
|
+
f"{v['drill']:.4f} | {span} |"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
if matched_zones:
|
|
445
|
+
lines += [
|
|
446
|
+
"",
|
|
447
|
+
"## Copper Zones",
|
|
448
|
+
"| Net | Layer |",
|
|
449
|
+
"|-----|-------|",
|
|
450
|
+
]
|
|
451
|
+
for z in matched_zones:
|
|
452
|
+
lines.append(f"| {z['net_name']} | {z.get('layer', 'N/A')} |")
|
|
453
|
+
|
|
454
|
+
return "\n".join(lines)
|
|
455
|
+
|
|
456
|
+
tracks = parser.get_tracks_by_net(net_name)
|
|
457
|
+
vias = parser.get_vias_for_net(net_name)
|
|
458
|
+
zones = parser.get_zones_for_net(net_name)
|
|
459
|
+
|
|
460
|
+
if not tracks and not vias:
|
|
461
|
+
# Try case-insensitive partial match
|
|
462
|
+
all_tracks = parser.get_tracks()
|
|
463
|
+
matching = [t for t in all_tracks if net_name.lower() in t.net.lower()]
|
|
464
|
+
if matching:
|
|
465
|
+
net_names = sorted({t.net for t in matching})
|
|
466
|
+
return (
|
|
467
|
+
f"# Tracks for '{net_name}'\n\n"
|
|
468
|
+
f"Exact match not found. Did you mean one of these?\n"
|
|
469
|
+
+ "\n".join(f"- `{n}` ({sum(1 for t in matching if t.net == n)} segments)" for n in net_names)
|
|
470
|
+
)
|
|
471
|
+
return f"No tracks or vias found for net '{net_name}'."
|
|
472
|
+
|
|
473
|
+
total_length = sum(t.length for t in tracks)
|
|
474
|
+
widths = [t.width for t in tracks]
|
|
475
|
+
layers = sorted({t.layer for t in tracks})
|
|
476
|
+
|
|
477
|
+
lines = [
|
|
478
|
+
f"# Track Analysis: {net_name}",
|
|
479
|
+
"",
|
|
480
|
+
f"**Track Segments:** {len(tracks)}",
|
|
481
|
+
f"**Vias:** {len(vias)}",
|
|
482
|
+
f"**Copper Zones:** {len(zones)}",
|
|
483
|
+
f"**Total Track Length:** {total_length:.3f} mm",
|
|
484
|
+
f"**Layers Used:** {', '.join(layers) if layers else 'N/A'}",
|
|
485
|
+
]
|
|
486
|
+
|
|
487
|
+
if widths:
|
|
488
|
+
unique_widths = sorted({f"{w:.4f}" for w in widths})
|
|
489
|
+
lines.append(f"**Track Widths:** {', '.join(unique_widths)} mm")
|
|
490
|
+
if len({round(w, 4) for w in widths}) > 1:
|
|
491
|
+
lines.append("⚠️ Mixed track widths detected — may indicate manual routing or design rule override.")
|
|
492
|
+
|
|
493
|
+
# Track segment details
|
|
494
|
+
if tracks:
|
|
495
|
+
lines += [
|
|
496
|
+
"",
|
|
497
|
+
"## Track Segments",
|
|
498
|
+
"| # | Start | End | Width | Layer | Length |",
|
|
499
|
+
"|---|-------|-----|-------|-------|--------|",
|
|
500
|
+
]
|
|
501
|
+
for i, t in enumerate(tracks[:50], 1):
|
|
502
|
+
lines.append(
|
|
503
|
+
f"| {i} | ({t.start[0]:.2f}, {t.start[1]:.2f}) | "
|
|
504
|
+
f"({t.end[0]:.2f}, {t.end[1]:.2f}) | {t.width:.4f} | "
|
|
505
|
+
f"{t.layer} | {t.length:.3f} |"
|
|
506
|
+
)
|
|
507
|
+
if len(tracks) > 50:
|
|
508
|
+
lines.append(f"| ... | ({len(tracks) - 50} more segments) | | | | |")
|
|
509
|
+
|
|
510
|
+
# Via details
|
|
511
|
+
if vias:
|
|
512
|
+
lines += [
|
|
513
|
+
"",
|
|
514
|
+
"## Vias",
|
|
515
|
+
"| # | Position | Size | Drill | Span |",
|
|
516
|
+
"|---|----------|------|-------|------|",
|
|
517
|
+
]
|
|
518
|
+
for i, v in enumerate(vias[:20], 1):
|
|
519
|
+
lines.append(
|
|
520
|
+
f"| {i} | ({v.position[0]:.2f}, {v.position[1]:.2f}) | "
|
|
521
|
+
f"{v.size:.3f} | {v.drill:.3f} | {v.top_layer} -> {v.bottom_layer} |"
|
|
522
|
+
)
|
|
523
|
+
if len(vias) > 20:
|
|
524
|
+
lines.append(f"| ... | ({len(vias) - 20} more vias) | | | |")
|
|
525
|
+
|
|
526
|
+
# Zone details
|
|
527
|
+
if zones:
|
|
528
|
+
lines += [
|
|
529
|
+
"",
|
|
530
|
+
"## Copper Zones",
|
|
531
|
+
"| Net | Layer | Filled |",
|
|
532
|
+
"|-----|-------|--------|",
|
|
533
|
+
]
|
|
534
|
+
for z in zones:
|
|
535
|
+
lines.append(f"| {z.net_name} | {z.layer} | {'Yes' if z.filled else 'No'} |")
|
|
536
|
+
|
|
537
|
+
return "\n".join(lines)
|
|
538
|
+
|
|
539
|
+
except FileNotFoundError as e:
|
|
540
|
+
return f"Error: {e}"
|
|
541
|
+
except Exception as e:
|
|
542
|
+
return f"Error parsing PCB: {e}"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@mcp.tool()
|
|
546
|
+
async def analyze_pcb_signal_integrity(
|
|
547
|
+
file_path: str,
|
|
548
|
+
net_name: str = "",
|
|
549
|
+
) -> str:
|
|
550
|
+
"""Analyze signal integrity: diff pair matching, RF traces, critical nets.
|
|
551
|
+
|
|
552
|
+
Auto-detects differential pairs (e.g., USB_N/USB_P, DP/DN) and checks
|
|
553
|
+
length matching. Reports RF trace dimensions and longest signal nets.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
file_path: Path to .kicad_pcb file
|
|
557
|
+
net_name: Optional specific net to analyze in detail
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
Signal integrity analysis report
|
|
561
|
+
"""
|
|
562
|
+
try:
|
|
563
|
+
parser = _get_parser(file_path)
|
|
564
|
+
|
|
565
|
+
if not _is_pcbnew(parser):
|
|
566
|
+
data = parser._parse_file()
|
|
567
|
+
tracks = data["tracks"]
|
|
568
|
+
if not tracks:
|
|
569
|
+
return "No tracks found in PCB file."
|
|
570
|
+
|
|
571
|
+
# Build net stats from text parser data
|
|
572
|
+
net_stats: dict[str, dict] = {}
|
|
573
|
+
for t in tracks:
|
|
574
|
+
net = t.get("net", "(unnamed)")
|
|
575
|
+
if net not in net_stats:
|
|
576
|
+
net_stats[net] = {"total_length": 0.0, "segment_count": 0, "widths": [], "layers": set()}
|
|
577
|
+
s = net_stats[net]
|
|
578
|
+
s["total_length"] += t.get("length", 0)
|
|
579
|
+
s["segment_count"] += 1
|
|
580
|
+
s["widths"].append(t["width"])
|
|
581
|
+
s["layers"].add(t["layer"])
|
|
582
|
+
|
|
583
|
+
lines = [
|
|
584
|
+
f"# Signal Integrity Analysis: {file_path}",
|
|
585
|
+
"",
|
|
586
|
+
"⚠️ pcbnew not available — text-based analysis (limited)",
|
|
587
|
+
]
|
|
588
|
+
|
|
589
|
+
# Auto-detect differential pairs
|
|
590
|
+
diff_pairs = _detect_diff_pairs(set(net_stats.keys()))
|
|
591
|
+
if diff_pairs:
|
|
592
|
+
lines += [
|
|
593
|
+
"",
|
|
594
|
+
"## Differential Pair Analysis",
|
|
595
|
+
"| Pair | Net P | Net N | Length P | Length N | Delta | Status |",
|
|
596
|
+
"|------|-------|-------|----------|----------|--------|---------|",
|
|
597
|
+
]
|
|
598
|
+
for pair_name, net_p, net_n in diff_pairs:
|
|
599
|
+
len_p = net_stats[net_p]["total_length"] if net_p in net_stats else 0
|
|
600
|
+
len_n = net_stats[net_n]["total_length"] if net_n in net_stats else 0
|
|
601
|
+
delta = abs(len_p - len_n)
|
|
602
|
+
if delta < 0.127:
|
|
603
|
+
status = "OK"
|
|
604
|
+
elif delta < 0.5:
|
|
605
|
+
status = "⚠️ Marginal"
|
|
606
|
+
else:
|
|
607
|
+
status = "❌ Mismatch"
|
|
608
|
+
lines.append(
|
|
609
|
+
f"| {pair_name} | {net_p} | {net_n} | {len_p:.3f} mm | "
|
|
610
|
+
f"{len_n:.3f} mm | {delta:.3f} mm | {status} |"
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
# Longest signal nets
|
|
614
|
+
power_keywords = ("VDD", "VCC", "GND", "VSS", "3V3", "5V", "1V8", "PGND", "VBUS")
|
|
615
|
+
signal_nets = [
|
|
616
|
+
(name, s) for name, s in net_stats.items()
|
|
617
|
+
if not any(kw in name.upper() for kw in power_keywords)
|
|
618
|
+
]
|
|
619
|
+
top_signals = sorted(signal_nets, key=lambda x: -x[1]["total_length"])[:15]
|
|
620
|
+
if top_signals:
|
|
621
|
+
lines += [
|
|
622
|
+
"",
|
|
623
|
+
"## Longest Signal Nets (Top 15)",
|
|
624
|
+
"| Net | Length (mm) | Segments | Layers |",
|
|
625
|
+
"|-----|-------------|----------|--------|",
|
|
626
|
+
]
|
|
627
|
+
for name, s in top_signals:
|
|
628
|
+
layers = ", ".join(sorted(s["layers"]))
|
|
629
|
+
lines.append(f"| {name} | {s['total_length']:.2f} | {s['segment_count']} | {layers} |")
|
|
630
|
+
|
|
631
|
+
return "\n".join(lines)
|
|
632
|
+
|
|
633
|
+
net_stats = parser.get_net_track_stats()
|
|
634
|
+
rules = parser.get_design_rules()
|
|
635
|
+
|
|
636
|
+
lines = [
|
|
637
|
+
f"# Signal Integrity Analysis: {file_path}",
|
|
638
|
+
"",
|
|
639
|
+
f"**Diff Pair Design Rules:** width={rules['current_diff_pair_width_mm']:.4f} mm, "
|
|
640
|
+
f"gap={rules['current_diff_pair_gap_mm']:.4f} mm",
|
|
641
|
+
]
|
|
642
|
+
|
|
643
|
+
# If specific net requested, show detail
|
|
644
|
+
if net_name:
|
|
645
|
+
tracks = parser.get_tracks_by_net(net_name)
|
|
646
|
+
if not tracks:
|
|
647
|
+
return f"No tracks found for net '{net_name}'."
|
|
648
|
+
total = sum(t.length for t in tracks)
|
|
649
|
+
lines += [
|
|
650
|
+
"",
|
|
651
|
+
f"## Net: {net_name}",
|
|
652
|
+
f"**Segments:** {len(tracks)}, **Total Length:** {total:.3f} mm",
|
|
653
|
+
]
|
|
654
|
+
return "\n".join(lines)
|
|
655
|
+
|
|
656
|
+
# Auto-detect differential pairs
|
|
657
|
+
net_names = set(net_stats.keys())
|
|
658
|
+
diff_pairs = _detect_diff_pairs(net_names)
|
|
659
|
+
|
|
660
|
+
if diff_pairs:
|
|
661
|
+
lines += [
|
|
662
|
+
"",
|
|
663
|
+
"## Differential Pair Analysis",
|
|
664
|
+
"| Pair | Net P | Net N | Length P | Length N | Delta | Status |",
|
|
665
|
+
"|------|-------|-------|----------|----------|--------|---------|",
|
|
666
|
+
]
|
|
667
|
+
for pair_name, net_p, net_n in diff_pairs:
|
|
668
|
+
len_p = net_stats[net_p]["total_length"] if net_p in net_stats else 0
|
|
669
|
+
len_n = net_stats[net_n]["total_length"] if net_n in net_stats else 0
|
|
670
|
+
delta = abs(len_p - len_n)
|
|
671
|
+
# Length mismatch tolerance: 5 mil = 0.127 mm
|
|
672
|
+
if delta < 0.127:
|
|
673
|
+
status = "OK"
|
|
674
|
+
elif delta < 0.5:
|
|
675
|
+
status = "⚠️ Marginal"
|
|
676
|
+
else:
|
|
677
|
+
status = "❌ Mismatch"
|
|
678
|
+
lines.append(
|
|
679
|
+
f"| {pair_name} | {net_p} | {net_n} | {len_p:.3f} mm | "
|
|
680
|
+
f"{len_n:.3f} mm | {delta:.3f} mm | {status} |"
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
# RF traces (ANT, RF, antenna nets)
|
|
684
|
+
rf_nets = [n for n in net_stats if any(
|
|
685
|
+
kw in n.upper() for kw in ("ANT", "RF", "FEED")
|
|
686
|
+
) and "VDD" not in n.upper() and "GND" not in n.upper()]
|
|
687
|
+
|
|
688
|
+
if rf_nets:
|
|
689
|
+
lines += [
|
|
690
|
+
"",
|
|
691
|
+
"## RF Traces",
|
|
692
|
+
"| Net | Length | Widths | Segments |",
|
|
693
|
+
"|-----|--------|--------|----------|",
|
|
694
|
+
]
|
|
695
|
+
for name in sorted(rf_nets):
|
|
696
|
+
s = net_stats[name]
|
|
697
|
+
widths = ", ".join(sorted({f"{w:.3f}" for w in s["widths"]}))
|
|
698
|
+
lines.append(f"| {name} | {s['total_length']:.3f} mm | {widths} mm | {s['segment_count']} |")
|
|
699
|
+
|
|
700
|
+
# Top signal nets by length (exclude power/GND)
|
|
701
|
+
power_keywords = ("VDD", "VCC", "GND", "VSS", "3V3", "5V", "1V8", "PGND", "VBUS")
|
|
702
|
+
signal_nets = [
|
|
703
|
+
(name, s) for name, s in net_stats.items()
|
|
704
|
+
if not any(kw in name.upper() for kw in power_keywords)
|
|
705
|
+
]
|
|
706
|
+
top_signals = sorted(signal_nets, key=lambda x: -x[1]["total_length"])[:15]
|
|
707
|
+
|
|
708
|
+
if top_signals:
|
|
709
|
+
lines += [
|
|
710
|
+
"",
|
|
711
|
+
"## Longest Signal Nets (Top 15)",
|
|
712
|
+
"| Net | Length (mm) | Segments | Layers |",
|
|
713
|
+
"|-----|-------------|----------|--------|",
|
|
714
|
+
]
|
|
715
|
+
for name, s in top_signals:
|
|
716
|
+
layers = ", ".join(s["layers"])
|
|
717
|
+
lines.append(f"| {name} | {s['total_length']:.2f} | {s['segment_count']} | {layers} |")
|
|
718
|
+
|
|
719
|
+
# Nets with most layer transitions (most vias = most transitions)
|
|
720
|
+
via_stats = parser.get_via_stats()
|
|
721
|
+
if via_stats["count"] > 0:
|
|
722
|
+
top_via_nets = list(via_stats["top_via_nets"].items())[:10]
|
|
723
|
+
lines += [
|
|
724
|
+
"",
|
|
725
|
+
"## Nets with Most Vias (Layer Transitions)",
|
|
726
|
+
"| Net | Via Count |",
|
|
727
|
+
"|-----|-----------|",
|
|
728
|
+
]
|
|
729
|
+
for name, count in top_via_nets:
|
|
730
|
+
lines.append(f"| {name} | {count} |")
|
|
731
|
+
|
|
732
|
+
return "\n".join(lines)
|
|
733
|
+
|
|
734
|
+
except FileNotFoundError as e:
|
|
735
|
+
return f"Error: {e}"
|
|
736
|
+
except Exception as e:
|
|
737
|
+
return f"Error parsing PCB: {e}"
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
@mcp.tool()
|
|
741
|
+
async def analyze_pcb_power_integrity(file_path: str) -> str:
|
|
742
|
+
"""Analyze power integrity: copper zones, power net routing, GND coverage.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
file_path: Path to .kicad_pcb file
|
|
746
|
+
|
|
747
|
+
Returns:
|
|
748
|
+
Power integrity analysis report
|
|
749
|
+
"""
|
|
750
|
+
try:
|
|
751
|
+
parser = _get_parser(file_path)
|
|
752
|
+
|
|
753
|
+
if not _is_pcbnew(parser):
|
|
754
|
+
data = parser._parse_file()
|
|
755
|
+
tracks = data["tracks"]
|
|
756
|
+
zones = data["zones"]
|
|
757
|
+
|
|
758
|
+
lines = [
|
|
759
|
+
f"# Power Integrity Analysis: {file_path}",
|
|
760
|
+
"",
|
|
761
|
+
"⚠️ pcbnew not available — text-based analysis (limited)",
|
|
762
|
+
]
|
|
763
|
+
|
|
764
|
+
# Power zones
|
|
765
|
+
power_keywords = ("VDD", "VCC", "VBUS", "LDO", "DCDC")
|
|
766
|
+
gnd_keywords = ("GND", "PGND", "AGND", "DGND")
|
|
767
|
+
|
|
768
|
+
power_zones = [z for z in zones if any(kw in z.get("net_name", "").upper() for kw in power_keywords)]
|
|
769
|
+
gnd_zones = [z for z in zones if any(kw in z.get("net_name", "").upper() for kw in gnd_keywords)]
|
|
770
|
+
|
|
771
|
+
if power_zones:
|
|
772
|
+
lines += [
|
|
773
|
+
"",
|
|
774
|
+
"## Power Copper Zones",
|
|
775
|
+
"| Net | Layer |",
|
|
776
|
+
"|-----|-------|",
|
|
777
|
+
]
|
|
778
|
+
for z in power_zones:
|
|
779
|
+
lines.append(f"| {z['net_name']} | {z.get('layer', 'N/A')} |")
|
|
780
|
+
|
|
781
|
+
if gnd_zones:
|
|
782
|
+
lines += [
|
|
783
|
+
"",
|
|
784
|
+
"## GND Copper Zones",
|
|
785
|
+
"| Net | Layer |",
|
|
786
|
+
"|-----|-------|",
|
|
787
|
+
]
|
|
788
|
+
for z in gnd_zones:
|
|
789
|
+
lines.append(f"| {z['net_name']} | {z.get('layer', 'N/A')} |")
|
|
790
|
+
gnd_layers = {z.get("layer", "") for z in gnd_zones}
|
|
791
|
+
lines.append(f"\n**GND Coverage:** {len(gnd_zones)} zones across {len(gnd_layers)} layers")
|
|
792
|
+
|
|
793
|
+
# Power net tracks
|
|
794
|
+
net_stats: dict[str, dict] = {}
|
|
795
|
+
for t in tracks:
|
|
796
|
+
net = t.get("net", "")
|
|
797
|
+
if not net:
|
|
798
|
+
continue
|
|
799
|
+
if net not in net_stats:
|
|
800
|
+
net_stats[net] = {"total_length": 0.0, "segment_count": 0, "widths": []}
|
|
801
|
+
net_stats[net]["total_length"] += t.get("length", 0)
|
|
802
|
+
net_stats[net]["segment_count"] += 1
|
|
803
|
+
net_stats[net]["widths"].append(t["width"])
|
|
804
|
+
|
|
805
|
+
power_tracks = [(n, s) for n, s in net_stats.items() if any(kw in n.upper() for kw in power_keywords)]
|
|
806
|
+
if power_tracks:
|
|
807
|
+
lines += [
|
|
808
|
+
"",
|
|
809
|
+
"## Power Net Track Routing",
|
|
810
|
+
"| Net | Length (mm) | Segments | Min Width | Max Width |",
|
|
811
|
+
"|-----|-------------|----------|-----------|-----------|",
|
|
812
|
+
]
|
|
813
|
+
for name, s in sorted(power_tracks, key=lambda x: -x[1]["total_length"]):
|
|
814
|
+
lines.append(
|
|
815
|
+
f"| {name} | {s['total_length']:.2f} | {s['segment_count']} | "
|
|
816
|
+
f"{min(s['widths']):.4f} mm | {max(s['widths']):.4f} mm |"
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
return "\n".join(lines)
|
|
820
|
+
|
|
821
|
+
net_stats = parser.get_net_track_stats()
|
|
822
|
+
zones = parser.get_zones()
|
|
823
|
+
rules = parser.get_design_rules()
|
|
824
|
+
|
|
825
|
+
lines = [
|
|
826
|
+
f"# Power Integrity Analysis: {file_path}",
|
|
827
|
+
"",
|
|
828
|
+
f"**Board:** {rules['copper_layers']} layers, {rules['board_thickness_mm']:.3f} mm thick",
|
|
829
|
+
]
|
|
830
|
+
|
|
831
|
+
# Identify power nets
|
|
832
|
+
power_keywords = ("VDD", "VCC", "VSS", "3V3", "5V", "1V8", "VBUS", "LDO", "DCDC")
|
|
833
|
+
gnd_keywords = ("GND", "PGND", "AGND", "DGND", "CHASSIS")
|
|
834
|
+
|
|
835
|
+
power_zones = [z for z in zones if any(kw in z.net_name.upper() for kw in power_keywords)]
|
|
836
|
+
gnd_zones = [z for z in zones if any(kw in z.net_name.upper() for kw in gnd_keywords)]
|
|
837
|
+
|
|
838
|
+
# Power zones by net
|
|
839
|
+
power_zone_by_net: dict[str, list[str]] = {}
|
|
840
|
+
for z in power_zones:
|
|
841
|
+
power_zone_by_net.setdefault(z.net_name, []).append(
|
|
842
|
+
f"{z.layer} ({'filled' if z.filled else 'unfilled'})"
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
if power_zone_by_net:
|
|
846
|
+
lines += [
|
|
847
|
+
"",
|
|
848
|
+
"## Power Copper Zones",
|
|
849
|
+
"| Net | Zones | Layers |",
|
|
850
|
+
"|-----|-------|--------|",
|
|
851
|
+
]
|
|
852
|
+
for net, layer_info in sorted(power_zone_by_net.items()):
|
|
853
|
+
layers_str = ", ".join(layer_info)
|
|
854
|
+
lines.append(f"| {net} | {len(layer_info)} | {layers_str} |")
|
|
855
|
+
|
|
856
|
+
# GND zones
|
|
857
|
+
gnd_zone_by_net: dict[str, list[str]] = {}
|
|
858
|
+
for z in gnd_zones:
|
|
859
|
+
gnd_zone_by_net.setdefault(z.net_name, []).append(
|
|
860
|
+
f"{z.layer} ({'filled' if z.filled else 'unfilled'})"
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
if gnd_zone_by_net:
|
|
864
|
+
lines += [
|
|
865
|
+
"",
|
|
866
|
+
"## GND Copper Zones",
|
|
867
|
+
"| Net | Zones | Layers |",
|
|
868
|
+
"|-----|-------|--------|",
|
|
869
|
+
]
|
|
870
|
+
for net, layer_info in sorted(gnd_zone_by_net.items()):
|
|
871
|
+
layers_str = ", ".join(layer_info)
|
|
872
|
+
lines.append(f"| {net} | {len(layer_info)} | {layers_str} |")
|
|
873
|
+
|
|
874
|
+
total_gnd_zones = len(gnd_zones)
|
|
875
|
+
filled_gnd_zones = sum(1 for z in gnd_zones if z.filled)
|
|
876
|
+
gnd_layers = {z.layer for z in gnd_zones}
|
|
877
|
+
lines += [
|
|
878
|
+
"",
|
|
879
|
+
f"**GND Coverage:** {total_gnd_zones} zones across {len(gnd_layers)} layers "
|
|
880
|
+
f"({filled_gnd_zones} filled)",
|
|
881
|
+
]
|
|
882
|
+
if rules["copper_layers"] > 2 and len(gnd_layers) < rules["copper_layers"]:
|
|
883
|
+
lines.append(
|
|
884
|
+
f"⚠️ GND zones only on {len(gnd_layers)} of {rules['copper_layers']} copper layers. "
|
|
885
|
+
"Consider adding GND pour on inner layers for better EMI performance."
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
# Power net track routing
|
|
889
|
+
power_track_nets = [
|
|
890
|
+
(name, s) for name, s in net_stats.items()
|
|
891
|
+
if any(kw in name.upper() for kw in power_keywords)
|
|
892
|
+
]
|
|
893
|
+
if power_track_nets:
|
|
894
|
+
lines += [
|
|
895
|
+
"",
|
|
896
|
+
"## Power Net Track Routing",
|
|
897
|
+
"| Net | Length (mm) | Segments | Min Width | Max Width |",
|
|
898
|
+
"|-----|-------------|----------|-----------|-----------|",
|
|
899
|
+
]
|
|
900
|
+
for name, s in sorted(power_track_nets, key=lambda x: -x[1]["total_length"]):
|
|
901
|
+
min_w = min(s["widths"]) if s["widths"] else 0
|
|
902
|
+
max_w = max(s["widths"]) if s["widths"] else 0
|
|
903
|
+
lines.append(
|
|
904
|
+
f"| {name} | {s['total_length']:.2f} | {s['segment_count']} | "
|
|
905
|
+
f"{min_w:.4f} mm | {max_w:.4f} mm |"
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
return "\n".join(lines)
|
|
909
|
+
|
|
910
|
+
except FileNotFoundError as e:
|
|
911
|
+
return f"Error: {e}"
|
|
912
|
+
except Exception as e:
|
|
913
|
+
return f"Error parsing PCB: {e}"
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
# ── Helpers ──────────────────────────────────────────────────
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _detect_diff_pairs(net_names: set[str]) -> list[tuple[str, str, str]]:
|
|
920
|
+
"""Auto-detect differential pairs from net names.
|
|
921
|
+
|
|
922
|
+
Returns list of (pair_name, net_p, net_n) tuples.
|
|
923
|
+
"""
|
|
924
|
+
pairs = []
|
|
925
|
+
seen = set()
|
|
926
|
+
|
|
927
|
+
# Common suffix patterns: _P/_N, _DP/_DN, _TXP/_TXN, etc.
|
|
928
|
+
suffix_pairs = [
|
|
929
|
+
("_P", "_N"), ("_DP", "_DN"), ("_TXP", "_TXN"), ("_RXP", "_RXN"),
|
|
930
|
+
("_M", "_S"), ("_POS", "_NEG"),
|
|
931
|
+
]
|
|
932
|
+
|
|
933
|
+
for suffix_p, suffix_n in suffix_pairs:
|
|
934
|
+
for name in net_names:
|
|
935
|
+
if name.endswith(suffix_p):
|
|
936
|
+
candidate_n = name[:-len(suffix_p)] + suffix_n
|
|
937
|
+
if candidate_n in net_names and name not in seen and candidate_n not in seen:
|
|
938
|
+
pair_name = name[:-len(suffix_p)]
|
|
939
|
+
pairs.append((pair_name, name, candidate_n))
|
|
940
|
+
seen.add(name)
|
|
941
|
+
seen.add(candidate_n)
|
|
942
|
+
|
|
943
|
+
# Also check for common keywords like USB_C_N/USB_C_P
|
|
944
|
+
keywords = ["USB", "DP", "HDMI", "CSI", "DSI", "SPI", "UART", "I2C"]
|
|
945
|
+
for kw in keywords:
|
|
946
|
+
p_candidates = [n for n in net_names if kw in n.upper() and n.endswith("_P") and n not in seen]
|
|
947
|
+
for p in p_candidates:
|
|
948
|
+
base = p[:-2]
|
|
949
|
+
n_candidate = base + "_N"
|
|
950
|
+
if n_candidate in net_names and n_candidate not in seen:
|
|
951
|
+
pairs.append((base, p, n_candidate))
|
|
952
|
+
seen.add(p)
|
|
953
|
+
seen.add(n_candidate)
|
|
954
|
+
|
|
955
|
+
return pairs
|