@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.
Files changed (192) hide show
  1. package/README.md +116 -0
  2. package/config/mcp/antigravity.json +22 -0
  3. package/config/mcp/claude.json +22 -0
  4. package/config/mcp/codex.toml +25 -0
  5. package/docs/adr/ADR-001-KICAD-OPENSCAD-MCP-STRATEGY.md +334 -0
  6. package/docs/review_documentation.md +121 -0
  7. package/installer.js +358 -0
  8. package/mcp_servers/kicad-mcp-server/.env.example +22 -0
  9. package/mcp_servers/kicad-mcp-server/.github/workflows/ci.yml +36 -0
  10. package/mcp_servers/kicad-mcp-server/CLAUDE.md +487 -0
  11. package/mcp_servers/kicad-mcp-server/README.md +316 -0
  12. package/mcp_servers/kicad-mcp-server/docs/DEVICE_TREE.md +416 -0
  13. package/mcp_servers/kicad-mcp-server/docs/INSTALLATION.md +332 -0
  14. package/mcp_servers/kicad-mcp-server/docs/PIN_ANALYSIS.md +332 -0
  15. package/mcp_servers/kicad-mcp-server/docs/README.md +240 -0
  16. package/mcp_servers/kicad-mcp-server/docs/TESTING.md +613 -0
  17. package/mcp_servers/kicad-mcp-server/docs/VALIDATION.md +268 -0
  18. package/mcp_servers/kicad-mcp-server/pyproject.toml +96 -0
  19. package/mcp_servers/kicad-mcp-server/requirements-dev.txt +16 -0
  20. package/mcp_servers/kicad-mcp-server/requirements-test.txt +24 -0
  21. package/mcp_servers/kicad-mcp-server/requirements.txt +13 -0
  22. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__init__.py +3 -0
  23. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/__main__.py +17 -0
  24. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/config.py +46 -0
  25. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/__init__.py +1 -0
  26. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/models/types.py +87 -0
  27. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/__init__.py +1 -0
  28. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/netlist_parser.py +234 -0
  29. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser.py +375 -0
  30. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/pcb_parser_kicad.py +327 -0
  31. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/parsers/schematic_parser.py +902 -0
  32. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/server.py +71 -0
  33. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/__init__.py +1 -0
  34. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/arduino/connectivity_test.cpp.j2 +189 -0
  35. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/atmega.dts.j2 +77 -0
  36. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/esp32.dts.j2 +77 -0
  37. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/nrf52.dts.j2 +77 -0
  38. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/device_tree/stm32f4.dts.j2 +89 -0
  39. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/esp_idf/test_suite.c.j2 +340 -0
  40. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/pytest/test_connectivity.py.j2 +147 -0
  41. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/st_hal/hal_test.c.j2 +313 -0
  42. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_gpio_test.py.j2 +99 -0
  43. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_i2c_test.py.j2 +117 -0
  44. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_pinmux_test.py.j2 +43 -0
  45. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/pytest_spi_test.py.j2 +94 -0
  46. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_gpio_test.c.j2 +113 -0
  47. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_i2c_test.c.j2 +101 -0
  48. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/tests/unity_spi_test.c.j2 +94 -0
  49. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/templates/unittest/test_schematic.py.j2 +172 -0
  50. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/__init__.py +36 -0
  51. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/device_tree.py +1187 -0
  52. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/hierarchical_analysis.py +211 -0
  53. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/netlist.py +320 -0
  54. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/parts_registry.py +142 -0
  55. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb.py +955 -0
  56. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pcb_layout.py +308 -0
  57. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/pin_analysis.py +765 -0
  58. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/project.py +196 -0
  59. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic.py +319 -0
  60. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_editor.py +674 -0
  61. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/schematic_search.py +158 -0
  62. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/validation.py +866 -0
  63. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/tools/visualization.py +225 -0
  64. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/__init__.py +1 -0
  65. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/file_handlers.py +65 -0
  66. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_cli.py +103 -0
  67. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/kicad_version.py +103 -0
  68. package/mcp_servers/kicad-mcp-server/src/kicad_mcp_server/utils/parts_registry.py +197 -0
  69. package/mcp_servers/kicad-mcp-server/tests/__init__.py +1 -0
  70. package/mcp_servers/kicad-mcp-server/tests/examples/ESP32S3_TEST.md +219 -0
  71. package/mcp_servers/kicad-mcp-server/tests/fixtures/README.md +65 -0
  72. package/mcp_servers/kicad-mcp-server/tests/fixtures/__init__.py +1 -0
  73. package/mcp_servers/kicad-mcp-server/tests/fixtures/example_pcb.kicad_pcb +177 -0
  74. package/mcp_servers/kicad-mcp-server/tests/fixtures/example_schematic.kicad_sch +145 -0
  75. package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/child.kicad_sch +24 -0
  76. package/mcp_servers/kicad-mcp-server/tests/fixtures/hier/root.kicad_sch +38 -0
  77. package/mcp_servers/kicad-mcp-server/tests/test_tools/__init__.py +1 -0
  78. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_hierarchical_labels.py +195 -0
  79. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_kicad_cli.py +116 -0
  80. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_netlist_cache_path.py +29 -0
  81. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic.py +189 -0
  82. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_schematic_hierarchy.py +69 -0
  83. package/mcp_servers/kicad-mcp-server/tests/test_tools/test_visualization.py +136 -0
  84. package/mcp_servers/kicad-mcp-server/uv.lock +2873 -0
  85. package/mcp_servers/openscad-mcp-server/.dockerignore +9 -0
  86. package/mcp_servers/openscad-mcp-server/.github/workflows/test.yml +40 -0
  87. package/mcp_servers/openscad-mcp-server/Dockerfile +29 -0
  88. package/mcp_servers/openscad-mcp-server/LICENSE +21 -0
  89. package/mcp_servers/openscad-mcp-server/README.md +154 -0
  90. package/mcp_servers/openscad-mcp-server/docs/audit.md +56 -0
  91. package/mcp_servers/openscad-mcp-server/docs/docker.md +66 -0
  92. package/mcp_servers/openscad-mcp-server/docs/issue-followup.md +19 -0
  93. package/mcp_servers/openscad-mcp-server/docs/jetson.md +17 -0
  94. package/mcp_servers/openscad-mcp-server/glama.json +4 -0
  95. package/mcp_servers/openscad-mcp-server/legacy/README.md +15 -0
  96. package/mcp_servers/openscad-mcp-server/legacy/README.original.md +294 -0
  97. package/mcp_servers/openscad-mcp-server/legacy/implementation_plan.md +100 -0
  98. package/mcp_servers/openscad-mcp-server/legacy/old/download_sam2_checkpoint.py +115 -0
  99. package/mcp_servers/openscad-mcp-server/legacy/old/src/ai/sam_segmentation.py +209 -0
  100. package/mcp_servers/openscad-mcp-server/legacy/old/src/models/threestudio_generator.py +231 -0
  101. package/mcp_servers/openscad-mcp-server/legacy/old/src/workflow/image_to_model_pipeline.py +260 -0
  102. package/mcp_servers/openscad-mcp-server/legacy/old/test_sam2_segmentation.py +96 -0
  103. package/mcp_servers/openscad-mcp-server/legacy/requirements.txt +57 -0
  104. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/README.md +39 -0
  105. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/ai-driven-code-generation.md +122 -0
  106. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/decisions/export-formats.md +76 -0
  107. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/ai/ai_service.py.md +51 -0
  108. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/main.py.md +63 -0
  109. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/models/code_generator.py.md +63 -0
  110. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/files/src/nlp/parameter_extractor.py.md +63 -0
  111. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/ai/natural-language-processing.md +78 -0
  112. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/nlp/parameter-extraction.md +173 -0
  113. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/export-formats.md +91 -0
  114. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/openscad-basics.md +66 -0
  115. package/mcp_servers/openscad-mcp-server/legacy/rtfmd/knowledge/openscad/primitive-testing.md +79 -0
  116. package/mcp_servers/openscad-mcp-server/legacy/src/__init__.py +0 -0
  117. package/mcp_servers/openscad-mcp-server/legacy/src/ai/ai_service.py +257 -0
  118. package/mcp_servers/openscad-mcp-server/legacy/src/ai/gemini_api.py +161 -0
  119. package/mcp_servers/openscad-mcp-server/legacy/src/ai/venice_api.py +203 -0
  120. package/mcp_servers/openscad-mcp-server/legacy/src/config.py +121 -0
  121. package/mcp_servers/openscad-mcp-server/legacy/src/main.py +1456 -0
  122. package/mcp_servers/openscad-mcp-server/legacy/src/main.py.new +404 -0
  123. package/mcp_servers/openscad-mcp-server/legacy/src/main_remote.py +401 -0
  124. package/mcp_servers/openscad-mcp-server/legacy/src/models/__init__.py +0 -0
  125. package/mcp_servers/openscad-mcp-server/legacy/src/models/code_generator.py +321 -0
  126. package/mcp_servers/openscad-mcp-server/legacy/src/models/cuda_mvs.py +209 -0
  127. package/mcp_servers/openscad-mcp-server/legacy/src/models/scad_templates/basic_shapes.scad +144 -0
  128. package/mcp_servers/openscad-mcp-server/legacy/src/nlp/__init__.py +0 -0
  129. package/mcp_servers/openscad-mcp-server/legacy/src/nlp/parameter_extractor.py +388 -0
  130. package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/__init__.py +0 -0
  131. package/mcp_servers/openscad-mcp-server/legacy/src/openscad_wrapper/wrapper.py +418 -0
  132. package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/__init__.py +1 -0
  133. package/mcp_servers/openscad-mcp-server/legacy/src/printer_discovery/printer_discovery.py +471 -0
  134. package/mcp_servers/openscad-mcp-server/legacy/src/remote/connection_manager.py +537 -0
  135. package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_client.py +435 -0
  136. package/mcp_servers/openscad-mcp-server/legacy/src/remote/cuda_mvs_server.py +787 -0
  137. package/mcp_servers/openscad-mcp-server/legacy/src/remote/error_handling.py +415 -0
  138. package/mcp_servers/openscad-mcp-server/legacy/src/testing/__init__.py +0 -0
  139. package/mcp_servers/openscad-mcp-server/legacy/src/testing/primitive_tester.py +203 -0
  140. package/mcp_servers/openscad-mcp-server/legacy/src/testing/test_primitives.py +98 -0
  141. package/mcp_servers/openscad-mcp-server/legacy/src/utils/__init__.py +1 -0
  142. package/mcp_servers/openscad-mcp-server/legacy/src/utils/cad_exporter.py +241 -0
  143. package/mcp_servers/openscad-mcp-server/legacy/src/utils/format_validator.py +206 -0
  144. package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_exporter.py +140 -0
  145. package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_repair.py +91 -0
  146. package/mcp_servers/openscad-mcp-server/legacy/src/utils/stl_validator.py +123 -0
  147. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/__init__.py +0 -0
  148. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/headless_renderer.py +52 -0
  149. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/renderer.py +177 -0
  150. package/mcp_servers/openscad-mcp-server/legacy/src/visualization/web_interface.py +639 -0
  151. package/mcp_servers/openscad-mcp-server/legacy/src/workflow/image_approval.py +148 -0
  152. package/mcp_servers/openscad-mcp-server/legacy/src/workflow/multi_view_to_model_pipeline.py +338 -0
  153. package/mcp_servers/openscad-mcp-server/legacy/test_complete_workflow.py +374 -0
  154. package/mcp_servers/openscad-mcp-server/legacy/test_cuda_mvs.py +191 -0
  155. package/mcp_servers/openscad-mcp-server/legacy/test_gemini_api.py +168 -0
  156. package/mcp_servers/openscad-mcp-server/legacy/test_image_approval.py +192 -0
  157. package/mcp_servers/openscad-mcp-server/legacy/test_image_approval_workflow.py +251 -0
  158. package/mcp_servers/openscad-mcp-server/legacy/test_image_to_model_pipeline.py +145 -0
  159. package/mcp_servers/openscad-mcp-server/legacy/test_model_selection.py +41 -0
  160. package/mcp_servers/openscad-mcp-server/legacy/test_multi_view_pipeline.py +290 -0
  161. package/mcp_servers/openscad-mcp-server/legacy/test_primitives.sh +13 -0
  162. package/mcp_servers/openscad-mcp-server/legacy/test_rabbit_direct.py +71 -0
  163. package/mcp_servers/openscad-mcp-server/legacy/test_remote_cuda_mvs.py +283 -0
  164. package/mcp_servers/openscad-mcp-server/legacy/test_venice_example.py +69 -0
  165. package/mcp_servers/openscad-mcp-server/pyproject.toml +33 -0
  166. package/mcp_servers/openscad-mcp-server/requirements.txt +2 -0
  167. package/mcp_servers/openscad-mcp-server/scad/simple_cube.scad +2 -0
  168. package/mcp_servers/openscad-mcp-server/scripts/test_docker.py +220 -0
  169. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__init__.py +3 -0
  170. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/__main__.py +3 -0
  171. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/engine.py +94 -0
  172. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/geometry.py +242 -0
  173. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/server.py +245 -0
  174. package/mcp_servers/openscad-mcp-server/src/openscad_mcp/service.py +190 -0
  175. package/mcp_servers/openscad-mcp-server/tests/conftest.py +17 -0
  176. package/mcp_servers/openscad-mcp-server/tests/test_engine.py +37 -0
  177. package/mcp_servers/openscad-mcp-server/tests/test_geometry.py +106 -0
  178. package/mcp_servers/openscad-mcp-server/tests/test_integration.py +172 -0
  179. package/mcp_servers/openscad-mcp-server/tests/test_transports.py +314 -0
  180. package/mcp_servers/openscad-mcp-server/uv.lock +1123 -0
  181. package/package.json +44 -0
  182. package/scripts/install_mcps.sh +86 -0
  183. package/scripts/openscad_wrapper.sh +62 -0
  184. package/scripts/run_kicad_mcp.sh +25 -0
  185. package/scripts/run_openscad_mcp.sh +41 -0
  186. package/scripts/test_mcp_connection.py +626 -0
  187. package/scripts/test_mcp_connection.sh +168 -0
  188. package/skills/code-first-hardware-design/SKILL.md +260 -0
  189. package/skills/pcb-constraint-definition/SKILL.md +236 -0
  190. package/skills/pcb-layout-routing-automation/SKILL.md +153 -0
  191. package/skills/pcb-validation-dfm-signoff/SKILL.md +194 -0
  192. 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