@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,902 @@
1
+ """Schematic file parser wrapper using kicad-skip."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from ..utils.file_handlers import validate_kicad_file
9
+
10
+ # KiCad-defined boolean flags (fixed set, not user-extensible)
11
+ KICAD_SYMBOL_FLAGS = ("dnp", "in_bom", "on_board", "exclude_from_sim")
12
+ KICAD_FLAG_DEFAULTS = {"dnp": False, "in_bom": True, "on_board": True, "exclude_from_sim": False}
13
+
14
+
15
+ @dataclass
16
+ class SchematicComponent:
17
+ """Component from schematic file."""
18
+
19
+ reference: str
20
+ value: str
21
+ library_id: str
22
+ footprint: str | None = None
23
+ properties: dict[str, str] = field(default_factory=dict)
24
+ position: tuple[float, float] = (0.0, 0.0)
25
+ unit: int | None = None
26
+ pins: list[dict[str, Any]] = field(default_factory=list)
27
+ flags: dict[str, bool] = field(default_factory=lambda: dict(KICAD_FLAG_DEFAULTS))
28
+
29
+ @classmethod
30
+ def from_kicad_skip(cls, data: dict[str, Any]) -> "SchematicComponent":
31
+ """Create from kicad-skip data structure."""
32
+ # Extract properties
33
+ properties = {}
34
+ for prop in data.get("properties", []):
35
+ key = prop.get("key", "")
36
+ value = prop.get("value", "")
37
+ if key and value:
38
+ properties[key] = value
39
+
40
+ # Get position
41
+ at = data.get("at", {})
42
+ position = (float(at.get("x", 0)), float(at.get("y", 0)))
43
+
44
+ # Get unit
45
+ unit = data.get("unit")
46
+
47
+ return cls(
48
+ reference=data.get("reference", ""),
49
+ value=properties.get("Value", data.get("value", "")),
50
+ library_id=data.get("lib_id", ""),
51
+ footprint=properties.get("Footprint"),
52
+ properties=properties,
53
+ position=position,
54
+ unit=unit,
55
+ pins=data.get("pins", []),
56
+ flags=data.get("flags", dict(KICAD_FLAG_DEFAULTS)),
57
+ )
58
+
59
+
60
+ @dataclass
61
+ class SchematicNet:
62
+ """Net from schematic file."""
63
+
64
+ name: str
65
+ code: int
66
+ node_count: int = 0
67
+ pins: list[str] = field(default_factory=list)
68
+ type: str = "unknown"
69
+ position: tuple[float, float] = (0.0, 0.0)
70
+
71
+ @classmethod
72
+ def from_kicad_skip(cls, data: dict[str, Any]) -> "SchematicNet":
73
+ """Create from kicad-skip data structure."""
74
+ return cls(
75
+ name=data.get("name", ""),
76
+ code=data.get("code", 0),
77
+ type=data.get("type", "unknown"),
78
+ position=data.get("position", (0.0, 0.0)),
79
+ )
80
+
81
+
82
+ @dataclass
83
+ class SchematicPin:
84
+ """Pin definition from symbol."""
85
+
86
+ number: str
87
+ name: str
88
+ type: str
89
+
90
+ @classmethod
91
+ def from_kicad_skip(cls, data: dict[str, Any]) -> "SchematicPin":
92
+ """Create from kicad-skip data structure."""
93
+ return cls(
94
+ number=data.get("number", ""),
95
+ name=data.get("name", ""),
96
+ type=data.get("electrical_type", ""),
97
+ )
98
+
99
+
100
+ def _read_file_with_encoding_fallback(file_path: Path) -> str:
101
+ """Read file with multiple encoding fallback support.
102
+
103
+ Args:
104
+ file_path: Path to the file to read
105
+
106
+ Returns:
107
+ File content as string
108
+
109
+ Note:
110
+ Tries multiple encodings to handle different KiCad file formats
111
+ and potential encoding issues.
112
+ """
113
+ encodings = ['utf-8', 'latin-1', 'cp1252', 'iso-8859-1']
114
+ content = None
115
+
116
+ for encoding in encodings:
117
+ try:
118
+ content = file_path.read_text(encoding=encoding)
119
+ break
120
+ except UnicodeDecodeError:
121
+ continue
122
+
123
+ if content is None:
124
+ # Last resort: read with error handling
125
+ content = file_path.read_text(encoding='utf-8', errors='ignore')
126
+
127
+ return content
128
+
129
+
130
+ def _merge_duplicate_references(
131
+ components: list[SchematicComponent],
132
+ ) -> list[SchematicComponent]:
133
+ """Merge multi-unit symbol instances that share a reference.
134
+
135
+ KiCad stores each unit of a multi-unit symbol as its own symbol block
136
+ (often spread across sub-sheets), but the physical component is one
137
+ entity — netlist lookups and per-reference reports expect it once. Pin
138
+ metadata from all units is unioned by pin number; the first instance's
139
+ value/footprint wins (units share them by construction).
140
+ """
141
+ merged: dict[str, SchematicComponent] = {}
142
+ for comp in components:
143
+ key = comp.reference or f"__unannotated_{len(merged)}"
144
+ existing = merged.get(key)
145
+ if existing is None:
146
+ merged[key] = comp
147
+ continue
148
+ known_pins = {str(p.get("number", "")) for p in existing.pins}
149
+ for p in comp.pins:
150
+ if str(p.get("number", "")) not in known_pins:
151
+ existing.pins.append(p)
152
+ return list(merged.values())
153
+
154
+
155
+ class SchematicParser:
156
+ """Parser for KiCad schematic files (.kicad_sch)."""
157
+
158
+ def __init__(self, file_path: str) -> None:
159
+ """Initialize parser with schematic file.
160
+
161
+ Args:
162
+ file_path: Path to .kicad_sch file
163
+
164
+ Raises:
165
+ FileNotFoundError: If file doesn't exist
166
+ ValueError: If file is not a .kicad_sch file
167
+ """
168
+ self.file_path = validate_kicad_file(file_path, ".kicad_sch")
169
+ self._data: dict[str, Any] | None = None
170
+ self._lib_symbols_lookup: dict[str, dict[str, dict[str, str]]] = {}
171
+
172
+ def _parse_file(self) -> dict[str, Any]:
173
+ """Parse the schematic file.
174
+
175
+ Returns:
176
+ Parsed data structure
177
+
178
+ Note:
179
+ This is a simplified parser. For production use, integrate kicad-skip
180
+ or use kicad-netlist for proper parsing.
181
+ """
182
+ if self._data is not None:
183
+ return self._data
184
+
185
+ # Simple text-based parser for .kicad_sch (S-expression format)
186
+ # In production, use kicad-skip library
187
+
188
+ # Read file with encoding fallback support
189
+ content = _read_file_with_encoding_fallback(self.file_path)
190
+
191
+ # Parse lib_symbols first so _parse_components can use it
192
+ self._lib_symbols_lookup = self._parse_lib_symbols(content)
193
+
194
+ # Extract basic information using regex patterns
195
+ # This is a simplified implementation
196
+ self._data = {
197
+ "path": str(self.file_path),
198
+ "title_block": self._parse_title_block(content),
199
+ "components": self._parse_components(content),
200
+ "nets": self._parse_nets(content),
201
+ "sheets": self._parse_sheets(content),
202
+ }
203
+
204
+ return self._data
205
+
206
+ def _parse_lib_symbols(self, content: str) -> dict[str, dict[str, dict[str, str]]]:
207
+ """Parse lib_symbols section to extract pin names and electrical types.
208
+
209
+ Returns:
210
+ {lib_id: {pin_number: {"name": str, "electrical_type": str}}}
211
+ """
212
+ result = {}
213
+
214
+ # Find the (lib_symbols ...) block
215
+ ls_match = re.search(r'\(lib_symbols\b', content)
216
+ if not ls_match:
217
+ return result
218
+
219
+ # Extract the full lib_symbols block by counting parens
220
+ start = ls_match.start()
221
+ depth = 0
222
+ i = start
223
+ while i < len(content):
224
+ if content[i] == '(':
225
+ depth += 1
226
+ elif content[i] == ')':
227
+ depth -= 1
228
+ if depth == 0:
229
+ break
230
+ i += 1
231
+ ls_block = content[start:i + 1]
232
+
233
+ # Find top-level symbols (contain ":" like "Device:R") and extract their blocks
234
+ lines = ls_block.split('\n')
235
+ li = 0
236
+ while li < len(lines):
237
+ line = lines[li].strip()
238
+ top_match = re.match(r'\(symbol\s+"([^"]*:[^"]*)"', line)
239
+ if top_match:
240
+ lib_id = top_match.group(1)
241
+ # Extract full block for this top-level symbol
242
+ sym_lines = []
243
+ depth = 0
244
+ j = li
245
+ while j < len(lines):
246
+ cur = lines[j]
247
+ depth += cur.count('(') - cur.count(')')
248
+ sym_lines.append(cur)
249
+ if depth == 0 and len(sym_lines) > 1:
250
+ break
251
+ j += 1
252
+ sym_block = '\n'.join(sym_lines)
253
+
254
+ # Extract pins from the entire symbol block (including sub-symbols)
255
+ # Pin format: (pin <elec_type> <graphic> ... (name "X") (number "N"))
256
+ pin_pattern = re.compile(
257
+ r'\(pin\s+(\w+)\s+\w+\s[\s\S]*?'
258
+ r'\(name\s+"([^"]*)"[\s\S]*?\)'
259
+ r'\s*\(number\s+"([^"]*)"',
260
+ )
261
+ pins = {}
262
+ for pin_match in pin_pattern.finditer(sym_block):
263
+ elec_type = pin_match.group(1)
264
+ name = pin_match.group(2)
265
+ number = pin_match.group(3)
266
+ pins[number] = {
267
+ "name": name,
268
+ "electrical_type": elec_type,
269
+ }
270
+ if pins:
271
+ result[lib_id] = pins
272
+
273
+ li = j + 1
274
+ else:
275
+ li += 1
276
+
277
+ return result
278
+
279
+ def _parse_title_block(self, content: str) -> dict[str, str]:
280
+ """Parse title block from schematic."""
281
+ title_block = {
282
+ "title": "",
283
+ "date": "",
284
+ "rev": "",
285
+ "company": "",
286
+ "comment": "",
287
+ }
288
+
289
+ # Extract title block values using regex
290
+ patterns = {
291
+ "title": r'title\s+"([^"]*)"',
292
+ "date": r'date\s+"([^"]*)"',
293
+ "rev": r'rev\s+"([^"]*)"',
294
+ "company": r'company\s+"([^"]*)"',
295
+ "comment": r'comment\s+\d+\s+"([^"]*)"',
296
+ }
297
+
298
+ for key, pattern in patterns.items():
299
+ match = re.search(pattern, content)
300
+ if match:
301
+ title_block[key] = match.group(1)
302
+
303
+ return title_block
304
+
305
+ def _parse_components(self, content: str) -> list[dict[str, Any]]:
306
+ """Parse components from schematic."""
307
+ components = []
308
+
309
+ # Find all symbol instances
310
+ # Pattern: (symbol (at x y rotation) (lib_id "...") ... (property "Reference" "...") ...)
311
+ # Match from (symbol to the closing )
312
+ lines = content.split('\n')
313
+ i = 0
314
+ while i < len(lines):
315
+ line = lines[i].strip()
316
+ if line.startswith('(symbol') and not line.startswith('(symbol "'):
317
+ # Found a symbol instance, find the entire block
318
+ symbol_block = []
319
+ depth = 0
320
+ j = i
321
+ while j < len(lines):
322
+ current_line = lines[j]
323
+ # Count parentheses
324
+ depth += current_line.count('(') - current_line.count(')')
325
+ symbol_block.append(current_line)
326
+ if depth == 0 and len(symbol_block) > 1:
327
+ break
328
+ j += 1
329
+
330
+ block_text = '\n'.join(symbol_block)
331
+
332
+ # Extract lib_id
333
+ lib_id_match = re.search(r'\(lib_id\s+"([^"]+)"', block_text)
334
+ if not lib_id_match:
335
+ i = j + 1
336
+ continue
337
+
338
+ lib_id = lib_id_match.group(1)
339
+
340
+ # Extract position (at x y rotation)
341
+ at_match = re.search(r'^\s*\(at\s+([\d.]+)\s+([\d.]+)\s+(\d+)\)', block_text, re.MULTILINE)
342
+ if at_match:
343
+ x = float(at_match.group(1))
344
+ y = float(at_match.group(2))
345
+ _ = float(at_match.group(3))
346
+ else:
347
+ x, y, _ = 0.0, 0.0, 0.0
348
+
349
+ # Extract reference
350
+ ref_match = re.search(r'\(property\s+"Reference"\s+"([^"]+)"', block_text)
351
+ reference = ref_match.group(1) if ref_match else ""
352
+
353
+ # Extract value
354
+ value_match = re.search(r'\(property\s+"Value"\s+"([^"]+)"', block_text)
355
+ value = value_match.group(1) if value_match else ""
356
+
357
+ # Extract footprint
358
+ fp_match = re.search(r'\(property\s+"Footprint"\s+"([^"]+)"', block_text)
359
+ footprint = fp_match.group(1) if fp_match else None
360
+
361
+ # Extract pins and enrich with lib_symbols data
362
+ pins = []
363
+ lib_pin_data = self._lib_symbols_lookup.get(lib_id, {})
364
+ for pin_match in re.finditer(r'\(pin\s+"([^"]+)"', block_text):
365
+ pin_num = pin_match.group(1)
366
+ pin_info = lib_pin_data.get(pin_num, {})
367
+ pins.append({
368
+ "number": pin_num,
369
+ "name": pin_info.get("name", ""),
370
+ "electrical_type": pin_info.get("electrical_type", ""),
371
+ })
372
+
373
+ # Extract component flags
374
+ flags = dict(KICAD_FLAG_DEFAULTS)
375
+ for flag in KICAD_SYMBOL_FLAGS:
376
+ if f"({flag} yes)" in block_text:
377
+ flags[flag] = True
378
+ elif f"({flag} no)" in block_text:
379
+ flags[flag] = False
380
+
381
+ # Skip library symbols (no reference)
382
+ if reference and not reference.startswith('#'):
383
+ # Build properties
384
+ properties = [
385
+ {"key": "Reference", "value": reference},
386
+ {"key": "Value", "value": value},
387
+ ]
388
+ if footprint:
389
+ properties.append({"key": "Footprint", "value": footprint})
390
+
391
+ components.append({
392
+ "lib_id": lib_id,
393
+ "reference": reference,
394
+ "value": value,
395
+ "properties": properties,
396
+ "at": {"x": x, "y": y},
397
+ "pins": pins,
398
+ "flags": flags,
399
+ })
400
+
401
+ i = j + 1
402
+ else:
403
+ i += 1
404
+
405
+ return components
406
+
407
+ def _parse_nets(self, content: str) -> list[dict[str, Any]]:
408
+ """Parse nets from schematic."""
409
+ nets = {}
410
+
411
+ # KiCad 9.0+ uses global_label, label, and wire to define nets
412
+ # Extract global labels
413
+ global_label_pattern = r'\(global_label\s+"([^"]+)"[\s\S]*?\(at\s+([\d.]+)\s+([\d.]+)[^\)]*\)'
414
+ for match in re.finditer(global_label_pattern, content):
415
+ name = match.group(1)
416
+ x = float(match.group(2))
417
+ y = float(match.group(3))
418
+ nets[name] = {
419
+ "name": name,
420
+ "code": len(nets),
421
+ "type": "global",
422
+ "position": (x, y),
423
+ }
424
+
425
+ # Extract local labels
426
+ label_pattern = r'\(label\s+"([^"]+)"[\s\S]*?\(at\s+([\d.]+)\s+([\d.]+)[^\)]*\)'
427
+ for match in re.finditer(label_pattern, content):
428
+ name = match.group(1)
429
+ if name not in nets: # Avoid duplicates
430
+ x = float(match.group(2))
431
+ y = float(match.group(3))
432
+ nets[name] = {
433
+ "name": name,
434
+ "code": len(nets),
435
+ "type": "local",
436
+ "position": (x, y),
437
+ }
438
+
439
+ # Extract hierarchical labels (connections to parent/child sheets)
440
+ h_label_pattern = r'\(hierarchical_label\s+"([^"]+)"[\s\S]*?\(at\s+([\d.]+)\s+([\d.]+)[^\)]*\)'
441
+ for match in re.finditer(h_label_pattern, content):
442
+ name = match.group(1)
443
+ if name not in nets: # Avoid duplicates
444
+ x = float(match.group(2))
445
+ y = float(match.group(3))
446
+ nets[name] = {
447
+ "name": name,
448
+ "code": len(nets),
449
+ "type": "hierarchical",
450
+ "position": (x, y),
451
+ }
452
+
453
+ # Extract power port labels (like +3V3, GND, etc.)
454
+ power_pattern = r'\(symbol\s+\(lib_id\s+"power:([^"]+)"[\s\S]*?\(at\s+([\d.]+)\s+([\d.]+)'
455
+ for match in re.finditer(power_pattern, content):
456
+ name = match.group(1)
457
+ if name not in nets: # Avoid duplicates
458
+ x = float(match.group(2))
459
+ y = float(match.group(3))
460
+ nets[name] = {
461
+ "name": name,
462
+ "code": len(nets),
463
+ "type": "power",
464
+ "position": (x, y),
465
+ }
466
+
467
+ return list(nets.values())
468
+
469
+ def _parse_sheets(self, content: str) -> list[dict[str, str]]:
470
+ """Parse hierarchical sheets."""
471
+ sheets = []
472
+
473
+ # Match sheet blocks and extract Sheetname/Sheetfile properties
474
+ # KiCad 10+ adds extra attributes between (size ...) and (property ...),
475
+ # so we use a flexible pattern with [\s\S]*? to skip them.
476
+ sheet_pattern = (
477
+ r'\(sheet\s+\(at\s+[\d.]+\s+[\d.]+\)\s*'
478
+ r'\(size\s+[\d.]+\s+[\d.]+\)'
479
+ r'[\s\S]*?'
480
+ r'\(property\s+"Sheetname"\s+"([^"]+)"'
481
+ r'[\s\S]*?'
482
+ r'\(property\s+"Sheetfile"\s+"([^"]+)"'
483
+ )
484
+
485
+ for match in re.finditer(sheet_pattern, content):
486
+ sheets.append({
487
+ "name": match.group(1),
488
+ "file": match.group(2),
489
+ })
490
+
491
+ return sheets
492
+
493
+ def get_components(self, recursive: bool = False) -> list[SchematicComponent]:
494
+ """Get all components from schematic.
495
+
496
+ Args:
497
+ recursive: Also include components from hierarchical sub-sheets,
498
+ following Sheetfile references (depth-first, cycle-safe).
499
+ Real designs keep most components in sub-sheets, so callers
500
+ that cross-reference a full-hierarchy netlist must pass True.
501
+
502
+ Returns:
503
+ List of components
504
+ """
505
+ data = self._parse_file()
506
+ components = [SchematicComponent.from_kicad_skip(c) for c in data["components"]]
507
+ if recursive:
508
+ seen = {str(self.file_path.resolve())}
509
+ for sheet in data["sheets"]:
510
+ components.extend(self._collect_subsheet_components(sheet["file"], seen))
511
+ components = _merge_duplicate_references(components)
512
+ return components
513
+
514
+ def _collect_subsheet_components(
515
+ self, sheet_file: str, seen: set[str]
516
+ ) -> list[SchematicComponent]:
517
+ """Recursively collect components from a referenced sub-sheet.
518
+
519
+ Missing or unparsable sub-sheets are skipped (a broken reference
520
+ must not wipe out the parent's components).
521
+ """
522
+ sub_path = self.file_path.parent / sheet_file
523
+ try:
524
+ key = str(sub_path.resolve())
525
+ except OSError:
526
+ return []
527
+ if key in seen or not sub_path.is_file():
528
+ return []
529
+ seen.add(key)
530
+ try:
531
+ sub = SchematicParser(str(sub_path))
532
+ sub_data = sub._parse_file()
533
+ except (FileNotFoundError, ValueError, OSError):
534
+ return []
535
+ components = [SchematicComponent.from_kicad_skip(c) for c in sub_data["components"]]
536
+ for sheet in sub_data["sheets"]:
537
+ components.extend(sub._collect_subsheet_components(sheet["file"], seen))
538
+ return components
539
+
540
+ def get_nets(self) -> list[SchematicNet]:
541
+ """Get all nets from schematic.
542
+
543
+ Returns:
544
+ List of nets
545
+ """
546
+ data = self._parse_file()
547
+ return [SchematicNet.from_kicad_skip(n) for n in data["nets"]]
548
+
549
+ def get_title_block(self) -> dict[str, str]:
550
+ """Get title block information.
551
+
552
+ Returns:
553
+ Dictionary with title, date, rev, company, comment
554
+ """
555
+ data = self._parse_file()
556
+ return data["title_block"]
557
+
558
+ def get_sheets(self) -> list[dict[str, str]]:
559
+ """Get hierarchical sheets.
560
+
561
+ Returns:
562
+ List of sheet information
563
+ """
564
+ data = self._parse_file()
565
+ return data["sheets"]
566
+
567
+ def get_component_by_reference(self, reference: str) -> SchematicComponent | None:
568
+ """Get a component by its reference designator.
569
+
570
+ Args:
571
+ reference: Component reference (e.g., "R1", "U1")
572
+
573
+ Returns:
574
+ Component if found, None otherwise
575
+ """
576
+ for component in self.get_components():
577
+ if component.reference == reference:
578
+ return component
579
+ return None
580
+
581
+ def search_components(self, pattern: str) -> list[SchematicComponent]:
582
+ """Search for components by pattern.
583
+
584
+ Args:
585
+ pattern: Search pattern (matches reference, value, or library_id)
586
+
587
+ Returns:
588
+ List of matching components
589
+ """
590
+ import re
591
+
592
+ regex = re.compile(pattern, re.IGNORECASE)
593
+ results = []
594
+
595
+ for component in self.get_components():
596
+ if (
597
+ regex.search(component.reference)
598
+ or regex.search(component.value)
599
+ or regex.search(component.library_id)
600
+ ):
601
+ results.append(component)
602
+
603
+ return results
604
+
605
+ def get_component_connections(self, reference: str) -> dict[str, Any]:
606
+ """Get all network connections for a component.
607
+
608
+ Args:
609
+ reference: Component reference (e.g., "R16")
610
+
611
+ Returns:
612
+ Dictionary with connection information:
613
+ {
614
+ "nets": ["net_name1", "net_name2"],
615
+ "labels": ["label1", "label2"],
616
+ "connected_components": ["comp1", "comp2"]
617
+ }
618
+ """
619
+ content = _read_file_with_encoding_fallback(self.file_path)
620
+
621
+ # Find the component instance
622
+ comp_pattern = rf'\(symbol\s+[\s\S]*?\(property\s+"Reference"\s+"{re.escape(reference)}"'
623
+ comp_match = re.search(comp_pattern, content, re.DOTALL)
624
+
625
+ if not comp_match:
626
+ return {"error": f"Component {reference} not found"}
627
+
628
+ # Extract the component block (from symbol to closing paren)
629
+ start = comp_match.start()
630
+ depth = 0
631
+ i = start
632
+ while i < len(content):
633
+ if content[i] == '(':
634
+ depth += 1
635
+ elif content[i] == ')':
636
+ depth -= 1
637
+ if depth == 0:
638
+ break
639
+ i += 1
640
+
641
+ comp_block = content[start:i]
642
+
643
+ # Find all pins in this component
644
+ pins = []
645
+ for pin_match in re.finditer(r'\(pin\s+"([^"]+)"', comp_block):
646
+ pins.append(pin_match.group(1))
647
+
648
+ # Search for connections by finding wires near the component position
649
+ comp = self.get_component_by_reference(reference)
650
+ if not comp:
651
+ return {"error": f"Component {reference} not found"}
652
+
653
+ cx, cy = comp.position
654
+
655
+ # Find all labels and global labels
656
+ labels = []
657
+ for label_match in re.finditer(r'\((?:global_)?label\s+"([^"]+)"[\s\S]*?\(at\s+([\d.]+)\s+([\d.]+)', content):
658
+ label_name = label_match.group(1)
659
+ lx, ly = float(label_match.group(2)), float(label_match.group(3))
660
+ dist = ((lx - cx)**2 + (ly - cy)**2)**0.5
661
+ labels.append({"name": label_name, "position": (lx, ly), "distance": dist})
662
+
663
+ # Find hierarchical labels
664
+ for label_match in re.finditer(r'\(hierarchical_label\s+"([^"]+)"[\s\S]*?\(at\s+([\d.]+)\s+([\d.]+)', content):
665
+ label_name = label_match.group(1)
666
+ lx, ly = float(label_match.group(2)), float(label_match.group(3))
667
+ dist = ((lx - cx)**2 + (ly - cy)**2)**0.5
668
+ labels.append({"name": label_name, "position": (lx, ly), "distance": dist})
669
+
670
+ # Filter to nearby labels (within 20mm)
671
+ nearby_labels = [label for label in labels if label["distance"] < 20]
672
+
673
+ # Find nearby components (within 15mm)
674
+ components = self.get_components()
675
+ nearby_comps = []
676
+ for c in components:
677
+ if c.reference == reference:
678
+ continue
679
+ dist = ((c.position[0] - cx)**2 + (c.position[1] - cy)**2)**0.5
680
+ if dist < 15:
681
+ nearby_comps.append({
682
+ "reference": c.reference,
683
+ "value": c.value,
684
+ "distance": dist,
685
+ "position": c.position
686
+ })
687
+
688
+ return {
689
+ "component": reference,
690
+ "position": comp.position,
691
+ "pins": pins,
692
+ "nearby_labels": nearby_labels[:10],
693
+ "nearby_components": nearby_comps[:10],
694
+ }
695
+
696
+ def trace_net(self, reference: str, pin_number: str | None = None) -> dict[str, Any]:
697
+ """Trace network connections from a component pin.
698
+
699
+ Args:
700
+ reference: Component reference (e.g., "R16")
701
+ pin_number: Optional pin number to trace (if None, trace all pins)
702
+
703
+ Returns:
704
+ Network trace information
705
+ """
706
+ connections = self.get_component_connections(reference)
707
+
708
+ if "error" in connections:
709
+ return connections
710
+
711
+ # Analyze the nearby data to infer network connections
712
+ inferred_nets = []
713
+
714
+ # Check for hierarchical labels that indicate function
715
+ for label in connections["nearby_labels"]:
716
+ label_name = label["name"]
717
+ if any(keyword in label_name.upper() for keyword in
718
+ ["I2C", "SCL", "SDA", "SMBUS", "PMIC", "GPIO", "EN", "INT"]):
719
+ inferred_nets.append({
720
+ "name": label_name,
721
+ "type": "signal",
722
+ "distance": label["distance"]
723
+ })
724
+
725
+ return {
726
+ "component": reference,
727
+ "position": connections["position"],
728
+ "inferred_connections": inferred_nets,
729
+ "nearby_components": connections["nearby_components"][:5],
730
+ }
731
+
732
+ def build_wire_network(self) -> dict[tuple[float, float], list[tuple[float, float]]]:
733
+ """Build a graph of wire connections.
734
+
735
+ Returns:
736
+ Dictionary mapping each point to its connected neighbors
737
+ """
738
+ import re
739
+
740
+ content = _read_file_with_encoding_fallback(self.file_path)
741
+
742
+ # Find all wire segments
743
+ wire_pattern = r'\(wire\s+\(pts\s+\(xy\s+([\d.]+)\s+([\d.]+)\)\s+\(xy\s+([\d.]+)\s+([\d.]+)\)'
744
+
745
+ network = {}
746
+
747
+ for match in re.finditer(wire_pattern, content):
748
+ x1, y1 = float(match.group(1)), float(match.group(2))
749
+ x2, y2 = float(match.group(3)), float(match.group(4))
750
+
751
+ p1 = (x1, y1)
752
+ p2 = (x2, y2)
753
+
754
+ if p1 not in network:
755
+ network[p1] = []
756
+ if p2 not in network:
757
+ network[p2] = []
758
+
759
+ network[p1].append(p2)
760
+ network[p2].append(p1)
761
+
762
+ # Find junctions and merge connections
763
+ junction_pattern = r'\(junction\s+\(at\s+([\d.]+)\s+([\d.]+)'
764
+ for match in re.finditer(junction_pattern, content):
765
+ jx, jy = float(match.group(1)), float(match.group(2))
766
+ # For a junction, all wires meeting at this point should be connected
767
+ # Find all wire endpoints at this position (with small tolerance)
768
+ tolerance = 0.01 # 0.01mm tolerance
769
+ connected_points = [p for p in network if
770
+ abs(p[0] - jx) < tolerance and abs(p[1] - jy) < tolerance]
771
+
772
+ # Merge all connections at junction
773
+ all_neighbors = set()
774
+ for p in connected_points:
775
+ all_neighbors.update(network[p])
776
+
777
+ for p in connected_points:
778
+ network[p] = list(all_neighbors)
779
+
780
+ return network
781
+
782
+ def trace_wire_network(self, reference: str, max_depth: int = 20) -> dict[str, Any]:
783
+ """Trace wire connections from a component.
784
+
785
+ Args:
786
+ reference: Component reference (e.g., "R16")
787
+ max_depth: Maximum connection depth to trace
788
+
789
+ Returns:
790
+ Dictionary with traced connections and labels
791
+ """
792
+ import re
793
+
794
+ # Get component position
795
+ comp = self.get_component_by_reference(reference)
796
+ if not comp:
797
+ return {"error": f"Component {reference} not found"}
798
+
799
+ cx, cy = comp.position
800
+
801
+ # Build wire network
802
+ network = self.build_wire_network()
803
+
804
+ # Find all labels
805
+ content = _read_file_with_encoding_fallback(self.file_path)
806
+
807
+ # Find hierarchical labels
808
+ h_labels = []
809
+ for label_match in re.finditer(
810
+ r'\(hierarchical_label\s+"([^"]+)"[\s\S]*?\(at\s+([\d.]+)\s+([\d.]+)',
811
+ content
812
+ ):
813
+ name = label_match.group(1)
814
+ lx, ly = float(label_match.group(2)), float(label_match.group(3))
815
+ h_labels.append({"name": name, "position": (lx, ly)})
816
+
817
+ # Find global labels
818
+ g_labels = []
819
+ for label_match in re.finditer(
820
+ r'\(global_label\s+"([^"]+)"[\s\S]*?\(at\s+([\d.]+)\s+([\d.]+)',
821
+ content
822
+ ):
823
+ name = label_match.group(1)
824
+ lx, ly = float(label_match.group(2)), float(label_match.group(3))
825
+ g_labels.append({"name": name, "position": (lx, ly)})
826
+
827
+ all_labels = h_labels + g_labels
828
+
829
+ # Start from component position and trace
830
+ max_tolerance = 20.0 # 20mm max tolerance (for components with pin offset)
831
+ start_point = None
832
+ min_dist = float('inf')
833
+
834
+ # Find the nearest wire endpoint to component
835
+ for point in network:
836
+ dist = ((point[0] - cx)**2 + (point[1] - cy)**2)**0.5
837
+ if dist < min_dist:
838
+ min_dist = dist
839
+ start_point = point
840
+
841
+ # Only proceed if the nearest point is within tolerance
842
+ if min_dist > max_tolerance:
843
+ return {
844
+ "component": reference,
845
+ "position": comp.position,
846
+ "connected_labels": [],
847
+ "trace_path": [],
848
+ "error": f"No wire found within {max_tolerance}mm (nearest: {min_dist:.2f}mm)",
849
+ }
850
+
851
+ # BFS trace through network
852
+ visited = set()
853
+ queue = [start_point]
854
+ trace_path = []
855
+ connected_labels = []
856
+
857
+ while queue and len(visited) < max_depth:
858
+ point = queue.pop(0)
859
+ if point in visited:
860
+ continue
861
+ visited.add(point)
862
+
863
+ trace_path.append(point)
864
+
865
+ # Check if this point is near a label
866
+ label_tolerance = 5.0 # 5mm tolerance for label matching
867
+ for label in all_labels:
868
+ lx, ly = label["position"]
869
+ dist = ((point[0] - lx)**2 + (point[1] - ly)**2)**0.5
870
+ if dist < label_tolerance and not any(lbl["name"] == label["name"] for lbl in connected_labels):
871
+ connected_labels.append({
872
+ "name": label["name"],
873
+ "position": label["position"],
874
+ "distance": dist,
875
+ })
876
+
877
+ # Add neighbors to queue
878
+ if point in network:
879
+ for neighbor in network[point]:
880
+ if neighbor not in visited:
881
+ queue.append(neighbor)
882
+
883
+ # Find nearby power symbols
884
+ power_tolerance = 15.0 # 15mm tolerance for power symbols
885
+ power_pattern = r'\(symbol\s+\(lib_id\s+"power:([^"]+)"[\s\S]*?\(at\s+([\d.]+)\s+([\d.]+)'
886
+ for match in re.finditer(power_pattern, content):
887
+ power_name = match.group(1)
888
+ px, py = float(match.group(2)), float(match.group(3))
889
+ dist = ((px - cx)**2 + (py - cy)**2)**0.5
890
+ if dist < power_tolerance:
891
+ connected_labels.append({
892
+ "name": f"POWER:{power_name}",
893
+ "position": (px, py),
894
+ "distance": dist,
895
+ })
896
+
897
+ return {
898
+ "component": reference,
899
+ "position": comp.position,
900
+ "connected_labels": connected_labels,
901
+ "trace_path": trace_path,
902
+ }