kicad-sch-api 0.3.0__py3-none-any.whl → 0.5.1__py3-none-any.whl

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 (112) hide show
  1. kicad_sch_api/__init__.py +68 -3
  2. kicad_sch_api/cli/__init__.py +45 -0
  3. kicad_sch_api/cli/base.py +302 -0
  4. kicad_sch_api/cli/bom.py +164 -0
  5. kicad_sch_api/cli/erc.py +229 -0
  6. kicad_sch_api/cli/export_docs.py +289 -0
  7. kicad_sch_api/cli/kicad_to_python.py +169 -0
  8. kicad_sch_api/cli/netlist.py +94 -0
  9. kicad_sch_api/cli/types.py +43 -0
  10. kicad_sch_api/collections/__init__.py +36 -0
  11. kicad_sch_api/collections/base.py +604 -0
  12. kicad_sch_api/collections/components.py +1623 -0
  13. kicad_sch_api/collections/junctions.py +206 -0
  14. kicad_sch_api/collections/labels.py +508 -0
  15. kicad_sch_api/collections/wires.py +292 -0
  16. kicad_sch_api/core/__init__.py +37 -2
  17. kicad_sch_api/core/collections/__init__.py +5 -0
  18. kicad_sch_api/core/collections/base.py +248 -0
  19. kicad_sch_api/core/component_bounds.py +34 -7
  20. kicad_sch_api/core/components.py +213 -52
  21. kicad_sch_api/core/config.py +110 -15
  22. kicad_sch_api/core/connectivity.py +692 -0
  23. kicad_sch_api/core/exceptions.py +175 -0
  24. kicad_sch_api/core/factories/__init__.py +5 -0
  25. kicad_sch_api/core/factories/element_factory.py +278 -0
  26. kicad_sch_api/core/formatter.py +60 -9
  27. kicad_sch_api/core/geometry.py +94 -5
  28. kicad_sch_api/core/junctions.py +26 -75
  29. kicad_sch_api/core/labels.py +324 -0
  30. kicad_sch_api/core/managers/__init__.py +30 -0
  31. kicad_sch_api/core/managers/base.py +76 -0
  32. kicad_sch_api/core/managers/file_io.py +246 -0
  33. kicad_sch_api/core/managers/format_sync.py +502 -0
  34. kicad_sch_api/core/managers/graphics.py +580 -0
  35. kicad_sch_api/core/managers/hierarchy.py +661 -0
  36. kicad_sch_api/core/managers/metadata.py +271 -0
  37. kicad_sch_api/core/managers/sheet.py +492 -0
  38. kicad_sch_api/core/managers/text_elements.py +537 -0
  39. kicad_sch_api/core/managers/validation.py +476 -0
  40. kicad_sch_api/core/managers/wire.py +410 -0
  41. kicad_sch_api/core/nets.py +305 -0
  42. kicad_sch_api/core/no_connects.py +252 -0
  43. kicad_sch_api/core/parser.py +194 -970
  44. kicad_sch_api/core/parsing_utils.py +63 -0
  45. kicad_sch_api/core/pin_utils.py +103 -9
  46. kicad_sch_api/core/schematic.py +1328 -1079
  47. kicad_sch_api/core/texts.py +316 -0
  48. kicad_sch_api/core/types.py +159 -23
  49. kicad_sch_api/core/wires.py +27 -75
  50. kicad_sch_api/exporters/__init__.py +10 -0
  51. kicad_sch_api/exporters/python_generator.py +610 -0
  52. kicad_sch_api/exporters/templates/default.py.jinja2 +65 -0
  53. kicad_sch_api/geometry/__init__.py +38 -0
  54. kicad_sch_api/geometry/font_metrics.py +22 -0
  55. kicad_sch_api/geometry/routing.py +211 -0
  56. kicad_sch_api/geometry/symbol_bbox.py +608 -0
  57. kicad_sch_api/interfaces/__init__.py +17 -0
  58. kicad_sch_api/interfaces/parser.py +76 -0
  59. kicad_sch_api/interfaces/repository.py +70 -0
  60. kicad_sch_api/interfaces/resolver.py +117 -0
  61. kicad_sch_api/parsers/__init__.py +14 -0
  62. kicad_sch_api/parsers/base.py +145 -0
  63. kicad_sch_api/parsers/elements/__init__.py +22 -0
  64. kicad_sch_api/parsers/elements/graphics_parser.py +564 -0
  65. kicad_sch_api/parsers/elements/label_parser.py +216 -0
  66. kicad_sch_api/parsers/elements/library_parser.py +165 -0
  67. kicad_sch_api/parsers/elements/metadata_parser.py +58 -0
  68. kicad_sch_api/parsers/elements/sheet_parser.py +352 -0
  69. kicad_sch_api/parsers/elements/symbol_parser.py +485 -0
  70. kicad_sch_api/parsers/elements/text_parser.py +250 -0
  71. kicad_sch_api/parsers/elements/wire_parser.py +242 -0
  72. kicad_sch_api/parsers/registry.py +155 -0
  73. kicad_sch_api/parsers/utils.py +80 -0
  74. kicad_sch_api/symbols/__init__.py +18 -0
  75. kicad_sch_api/symbols/cache.py +467 -0
  76. kicad_sch_api/symbols/resolver.py +361 -0
  77. kicad_sch_api/symbols/validators.py +504 -0
  78. kicad_sch_api/utils/logging.py +555 -0
  79. kicad_sch_api/utils/logging_decorators.py +587 -0
  80. kicad_sch_api/utils/validation.py +16 -22
  81. kicad_sch_api/validation/__init__.py +25 -0
  82. kicad_sch_api/validation/erc.py +171 -0
  83. kicad_sch_api/validation/erc_models.py +203 -0
  84. kicad_sch_api/validation/pin_matrix.py +243 -0
  85. kicad_sch_api/validation/validators.py +391 -0
  86. kicad_sch_api/wrappers/__init__.py +14 -0
  87. kicad_sch_api/wrappers/base.py +89 -0
  88. kicad_sch_api/wrappers/wire.py +198 -0
  89. kicad_sch_api-0.5.1.dist-info/METADATA +540 -0
  90. kicad_sch_api-0.5.1.dist-info/RECORD +114 -0
  91. kicad_sch_api-0.5.1.dist-info/entry_points.txt +4 -0
  92. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/top_level.txt +1 -0
  93. mcp_server/__init__.py +34 -0
  94. mcp_server/example_logging_integration.py +506 -0
  95. mcp_server/models.py +252 -0
  96. mcp_server/server.py +357 -0
  97. mcp_server/tools/__init__.py +32 -0
  98. mcp_server/tools/component_tools.py +516 -0
  99. mcp_server/tools/connectivity_tools.py +532 -0
  100. mcp_server/tools/consolidated_tools.py +1216 -0
  101. mcp_server/tools/pin_discovery.py +333 -0
  102. mcp_server/utils/__init__.py +38 -0
  103. mcp_server/utils/logging.py +127 -0
  104. mcp_server/utils.py +36 -0
  105. kicad_sch_api/core/manhattan_routing.py +0 -430
  106. kicad_sch_api/core/simple_manhattan.py +0 -228
  107. kicad_sch_api/core/wire_routing.py +0 -380
  108. kicad_sch_api-0.3.0.dist-info/METADATA +0 -483
  109. kicad_sch_api-0.3.0.dist-info/RECORD +0 -31
  110. kicad_sch_api-0.3.0.dist-info/entry_points.txt +0 -2
  111. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/WHEEL +0 -0
  112. {kicad_sch_api-0.3.0.dist-info → kicad_sch_api-0.5.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,608 @@
1
+ """
2
+ symbol_bbox.py
3
+
4
+ Calculate accurate bounding boxes for KiCad symbols based on their graphical elements.
5
+ This ensures proper spacing and collision detection in schematic layouts.
6
+
7
+ Migrated from circuit-synth to kicad-sch-api for better architectural separation.
8
+ """
9
+
10
+ import logging
11
+ import math
12
+ import os
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ from .font_metrics import (
16
+ DEFAULT_PIN_LENGTH,
17
+ DEFAULT_PIN_NAME_OFFSET,
18
+ DEFAULT_PIN_NUMBER_SIZE,
19
+ DEFAULT_PIN_TEXT_WIDTH_RATIO,
20
+ DEFAULT_TEXT_HEIGHT,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class SymbolBoundingBoxCalculator:
27
+ """Calculate the actual bounding box of a symbol from its graphical elements."""
28
+
29
+ # Re-export font metrics as class constants for backward compatibility
30
+ DEFAULT_TEXT_HEIGHT = DEFAULT_TEXT_HEIGHT
31
+ DEFAULT_PIN_LENGTH = DEFAULT_PIN_LENGTH
32
+ DEFAULT_PIN_NAME_OFFSET = DEFAULT_PIN_NAME_OFFSET
33
+ DEFAULT_PIN_NUMBER_SIZE = DEFAULT_PIN_NUMBER_SIZE
34
+ DEFAULT_PIN_TEXT_WIDTH_RATIO = DEFAULT_PIN_TEXT_WIDTH_RATIO
35
+
36
+ @classmethod
37
+ def calculate_bounding_box(
38
+ cls,
39
+ symbol_data: Dict[str, Any],
40
+ include_properties: bool = True,
41
+ hierarchical_labels: Optional[List[Dict[str, Any]]] = None,
42
+ pin_net_map: Optional[Dict[str, str]] = None,
43
+ ) -> Tuple[float, float, float, float]:
44
+ """
45
+ Calculate the actual bounding box of a symbol from its graphical elements.
46
+
47
+ Args:
48
+ symbol_data: Dictionary containing symbol definition from KiCad library
49
+ include_properties: Whether to include space for Reference/Value labels
50
+ hierarchical_labels: List of hierarchical labels attached to this symbol
51
+ pin_net_map: Optional mapping of pin numbers to net names (for accurate label sizing)
52
+
53
+ Returns:
54
+ Tuple of (min_x, min_y, max_x, max_y) in mm
55
+
56
+ Raises:
57
+ ValueError: If symbol data is invalid or bounding box cannot be calculated
58
+ """
59
+ if not symbol_data:
60
+ raise ValueError("Symbol data is None or empty")
61
+
62
+ # Use proper logging instead of print statements
63
+ logger.debug("=== CALCULATING BOUNDING BOX ===")
64
+ logger.debug(f"include_properties={include_properties}")
65
+
66
+ min_x = float("inf")
67
+ min_y = float("inf")
68
+ max_x = float("-inf")
69
+ max_y = float("-inf")
70
+
71
+ # Process main symbol shapes (handle both 'shapes' and 'graphics' keys)
72
+ shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", [])
73
+ logger.debug(f"Processing {len(shapes)} main shapes")
74
+ for shape in shapes:
75
+ shape_bounds = cls._get_shape_bounds(shape)
76
+ if shape_bounds:
77
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
78
+ min_x = min(min_x, s_min_x)
79
+ min_y = min(min_y, s_min_y)
80
+ max_x = max(max_x, s_max_x)
81
+ max_y = max(max_y, s_max_y)
82
+
83
+ # Process pins (including their labels)
84
+ pins = symbol_data.get("pins", [])
85
+ logger.debug(f"Processing {len(pins)} main pins")
86
+ for pin in pins:
87
+ pin_bounds = cls._get_pin_bounds(pin, pin_net_map)
88
+ if pin_bounds:
89
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
90
+ min_x = min(min_x, p_min_x)
91
+ min_y = min(min_y, p_min_y)
92
+ max_x = max(max_x, p_max_x)
93
+ max_y = max(max_y, p_max_y)
94
+
95
+ # Process sub-symbols
96
+ sub_symbols = symbol_data.get("sub_symbols", [])
97
+ for sub in sub_symbols:
98
+ # Sub-symbols can have their own shapes and pins (handle both 'shapes' and 'graphics' keys)
99
+ sub_shapes = sub.get("shapes", []) or sub.get("graphics", [])
100
+ for shape in sub_shapes:
101
+ shape_bounds = cls._get_shape_bounds(shape)
102
+ if shape_bounds:
103
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
104
+ min_x = min(min_x, s_min_x)
105
+ min_y = min(min_y, s_min_y)
106
+ max_x = max(max_x, s_max_x)
107
+ max_y = max(max_y, s_max_y)
108
+
109
+ sub_pins = sub.get("pins", [])
110
+ for pin in sub_pins:
111
+ pin_bounds = cls._get_pin_bounds(pin, pin_net_map)
112
+ if pin_bounds:
113
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
114
+ min_x = min(min_x, p_min_x)
115
+ min_y = min(min_y, p_min_y)
116
+ max_x = max(max_x, p_max_x)
117
+ max_y = max(max_y, p_max_y)
118
+
119
+ # Check if we found any geometry
120
+ if min_x == float("inf") or max_x == float("-inf"):
121
+ raise ValueError(f"No valid geometry found in symbol data")
122
+
123
+ logger.debug(
124
+ f"After geometry processing: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})"
125
+ )
126
+ logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}")
127
+
128
+ # Add small margin for text that might extend beyond shapes
129
+ margin = 0.254 # 10 mils
130
+ min_x -= margin
131
+ min_y -= margin
132
+ max_x += margin
133
+ max_y += margin
134
+
135
+ # Include space for component properties (Reference, Value, Footprint)
136
+ if include_properties:
137
+ # Use adaptive spacing based on component dimensions
138
+ component_width = max_x - min_x
139
+ component_height = max_y - min_y
140
+
141
+ # Adaptive property width: minimum 10mm or 80% of component width
142
+ property_width = max(10.0, component_width * 0.8)
143
+ property_height = cls.DEFAULT_TEXT_HEIGHT
144
+
145
+ # Adaptive vertical spacing: minimum 5mm or 10% of component height
146
+ vertical_spacing_above = max(5.0, component_height * 0.1)
147
+ vertical_spacing_below = max(10.0, component_height * 0.15)
148
+
149
+ # Reference label above
150
+ min_y -= vertical_spacing_above + property_height
151
+
152
+ # Value and Footprint labels below
153
+ max_y += vertical_spacing_below + property_height
154
+
155
+ # Extend horizontally for property text
156
+ center_x = (min_x + max_x) / 2
157
+ min_x = min(min_x, center_x - property_width / 2)
158
+ max_x = max(max_x, center_x + property_width / 2)
159
+
160
+ logger.debug(
161
+ f"Calculated bounding box: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})"
162
+ )
163
+
164
+ logger.debug(f"FINAL BBOX: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
165
+ logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}")
166
+ logger.debug("=" * 50)
167
+
168
+ return (min_x, min_y, max_x, max_y)
169
+
170
+ @classmethod
171
+ def calculate_placement_bounding_box(
172
+ cls,
173
+ symbol_data: Dict[str, Any],
174
+ ) -> Tuple[float, float, float, float]:
175
+ """
176
+ Calculate bounding box for PLACEMENT purposes - excludes pin labels.
177
+
178
+ This method calculates a tighter bounding box that only includes:
179
+ - Component body (shapes/graphics)
180
+ - Pin endpoints (without label text)
181
+ - Small margin for component properties
182
+
183
+ Pin label text is excluded because it extends arbitrarily far based on
184
+ text length and would cause incorrect spacing in text-flow placement.
185
+
186
+ Args:
187
+ symbol_data: Dictionary containing symbol definition from KiCad library
188
+
189
+ Returns:
190
+ Tuple of (min_x, min_y, max_x, max_y) in mm
191
+
192
+ Raises:
193
+ ValueError: If symbol data is invalid or bounding box cannot be calculated
194
+ """
195
+ if not symbol_data:
196
+ raise ValueError("Symbol data is None or empty")
197
+
198
+ logger.debug("=== CALCULATING PLACEMENT BOUNDING BOX (NO PIN LABELS) ===")
199
+
200
+ min_x = float("inf")
201
+ min_y = float("inf")
202
+ max_x = float("-inf")
203
+ max_y = float("-inf")
204
+
205
+ # Process main symbol shapes (handle both 'shapes' and 'graphics' keys)
206
+ shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", [])
207
+ logger.debug(f"Processing {len(shapes)} main shapes")
208
+ for shape in shapes:
209
+ shape_bounds = cls._get_shape_bounds(shape)
210
+ if shape_bounds:
211
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
212
+ min_x = min(min_x, s_min_x)
213
+ min_y = min(min_y, s_min_y)
214
+ max_x = max(max_x, s_max_x)
215
+ max_y = max(max_y, s_max_y)
216
+
217
+ # Process pins WITHOUT labels (just pin endpoints)
218
+ pins = symbol_data.get("pins", [])
219
+ logger.debug(f"Processing {len(pins)} main pins (NO LABELS)")
220
+ for pin in pins:
221
+ pin_bounds = cls._get_pin_bounds_no_labels(pin)
222
+ if pin_bounds:
223
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
224
+ min_x = min(min_x, p_min_x)
225
+ min_y = min(min_y, p_min_y)
226
+ max_x = max(max_x, p_max_x)
227
+ max_y = max(max_y, p_max_y)
228
+
229
+ # Process sub-symbols
230
+ sub_symbols = symbol_data.get("sub_symbols", [])
231
+ for sub in sub_symbols:
232
+ # Sub-symbols can have their own shapes and pins
233
+ sub_shapes = sub.get("shapes", []) or sub.get("graphics", [])
234
+ for shape in sub_shapes:
235
+ shape_bounds = cls._get_shape_bounds(shape)
236
+ if shape_bounds:
237
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
238
+ min_x = min(min_x, s_min_x)
239
+ min_y = min(min_y, s_min_y)
240
+ max_x = max(max_x, s_max_x)
241
+ max_y = max(max_y, s_max_y)
242
+
243
+ sub_pins = sub.get("pins", [])
244
+ for pin in sub_pins:
245
+ pin_bounds = cls._get_pin_bounds_no_labels(pin)
246
+ if pin_bounds:
247
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
248
+ min_x = min(min_x, p_min_x)
249
+ min_y = min(min_y, p_min_y)
250
+ max_x = max(max_x, p_max_x)
251
+ max_y = max(max_y, p_max_y)
252
+
253
+ # Check if we found any geometry
254
+ if min_x == float("inf") or max_x == float("-inf"):
255
+ raise ValueError(f"No valid geometry found in symbol data")
256
+
257
+ logger.debug(
258
+ f"After geometry processing: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})"
259
+ )
260
+ logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}")
261
+
262
+ # Add small margin for visual spacing
263
+ margin = 0.635 # 25mil margin (reduced from 50mil)
264
+ min_x -= margin
265
+ min_y -= margin
266
+ max_x += margin
267
+ max_y += margin
268
+
269
+ # Add minimal space for component properties (Reference above, Value below)
270
+ # Use adaptive spacing based on component height for better visual hierarchy
271
+ component_height = max_y - min_y
272
+ property_spacing = max(
273
+ 3.0, component_height * 0.15
274
+ ) # Adaptive: minimum 3mm or 15% of height
275
+ property_height = 1.27 # Reduced from 2.54mm
276
+ min_y -= property_spacing + property_height # Reference above
277
+ max_y += property_spacing + property_height # Value below
278
+
279
+ logger.debug(
280
+ f"FINAL PLACEMENT BBOX: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})"
281
+ )
282
+ logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}")
283
+ logger.debug("=" * 50)
284
+
285
+ return (min_x, min_y, max_x, max_y)
286
+
287
+ @classmethod
288
+ def calculate_visual_bounding_box(
289
+ cls, symbol_data: Dict[str, Any], pin_net_map: Optional[Dict[str, str]] = None
290
+ ) -> Tuple[float, float, float, float]:
291
+ """
292
+ Calculate bounding box for visual/debug drawing (includes pin labels, no property spacing).
293
+
294
+ This shows the actual component geometry including pin labels.
295
+ Use this for drawing bounding boxes on schematics.
296
+
297
+ Args:
298
+ symbol_data: Dictionary containing symbol definition
299
+ pin_net_map: Optional mapping of pin numbers to net names (for accurate label sizing)
300
+
301
+ Returns:
302
+ Tuple of (min_x, min_y, max_x, max_y) in mm
303
+ """
304
+ # Initialize bounds
305
+ min_x = float("inf")
306
+ min_y = float("inf")
307
+ max_x = float("-inf")
308
+ max_y = float("-inf")
309
+
310
+ # Process main symbol shapes
311
+ shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", [])
312
+ for shape in shapes:
313
+ shape_bounds = cls._get_shape_bounds(shape)
314
+ if shape_bounds:
315
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
316
+ min_x = min(min_x, s_min_x)
317
+ min_y = min(min_y, s_min_y)
318
+ max_x = max(max_x, s_max_x)
319
+ max_y = max(max_y, s_max_y)
320
+
321
+ # Process pins WITH labels to get accurate visual bounds
322
+ pins = symbol_data.get("pins", [])
323
+ for pin in pins:
324
+ pin_bounds = cls._get_pin_bounds(pin, pin_net_map)
325
+ if pin_bounds:
326
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
327
+ min_x = min(min_x, p_min_x)
328
+ min_y = min(min_y, p_min_y)
329
+ max_x = max(max_x, p_max_x)
330
+ max_y = max(max_y, p_max_y)
331
+
332
+ # Process sub-symbols
333
+ sub_symbols = symbol_data.get("sub_symbols", [])
334
+ for sub in sub_symbols:
335
+ sub_shapes = sub.get("shapes", []) or sub.get("graphics", [])
336
+ for shape in sub_shapes:
337
+ shape_bounds = cls._get_shape_bounds(shape)
338
+ if shape_bounds:
339
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
340
+ min_x = min(min_x, s_min_x)
341
+ min_y = min(min_y, s_min_y)
342
+ max_x = max(max_x, s_max_x)
343
+ max_y = max(max_y, s_max_y)
344
+
345
+ sub_pins = sub.get("pins", [])
346
+ for pin in sub_pins:
347
+ pin_bounds = cls._get_pin_bounds(pin, pin_net_map)
348
+ if pin_bounds:
349
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
350
+ min_x = min(min_x, p_min_x)
351
+ min_y = min(min_y, p_min_y)
352
+ max_x = max(max_x, p_max_x)
353
+ max_y = max(max_y, p_max_y)
354
+
355
+ # Check if we found any geometry
356
+ if min_x == float("inf") or max_x == float("-inf"):
357
+ raise ValueError(f"No valid geometry found in symbol data")
358
+
359
+ # Add only a tiny margin for visibility (no property spacing)
360
+ margin = 0.254 # 10mil minimal margin
361
+ min_x -= margin
362
+ min_y -= margin
363
+ max_x += margin
364
+ max_y += margin
365
+
366
+ return (min_x, min_y, max_x, max_y)
367
+
368
+ @classmethod
369
+ def get_symbol_dimensions(
370
+ cls,
371
+ symbol_data: Dict[str, Any],
372
+ include_properties: bool = True,
373
+ pin_net_map: Optional[Dict[str, str]] = None,
374
+ ) -> Tuple[float, float]:
375
+ """
376
+ Get the width and height of a symbol.
377
+
378
+ Args:
379
+ symbol_data: Dictionary containing symbol definition
380
+ include_properties: Whether to include space for Reference/Value labels
381
+ pin_net_map: Optional mapping of pin numbers to net names
382
+
383
+ Returns:
384
+ Tuple of (width, height) in mm
385
+ """
386
+ min_x, min_y, max_x, max_y = cls.calculate_bounding_box(
387
+ symbol_data, include_properties, pin_net_map=pin_net_map
388
+ )
389
+ width = max_x - min_x
390
+ height = max_y - min_y
391
+ return (width, height)
392
+
393
+ @classmethod
394
+ def _get_shape_bounds(
395
+ cls, shape: Dict[str, Any]
396
+ ) -> Optional[Tuple[float, float, float, float]]:
397
+ """Get bounding box for a graphical shape."""
398
+ shape_type = shape.get("shape_type", "")
399
+
400
+ if shape_type == "rectangle":
401
+ start = shape.get("start", [0, 0])
402
+ end = shape.get("end", [0, 0])
403
+ return (
404
+ min(start[0], end[0]),
405
+ min(start[1], end[1]),
406
+ max(start[0], end[0]),
407
+ max(start[1], end[1]),
408
+ )
409
+
410
+ elif shape_type == "circle":
411
+ center = shape.get("center", [0, 0])
412
+ radius = shape.get("radius", 0)
413
+ return (
414
+ center[0] - radius,
415
+ center[1] - radius,
416
+ center[0] + radius,
417
+ center[1] + radius,
418
+ )
419
+
420
+ elif shape_type == "arc":
421
+ # For arcs, we need to consider start, mid, and end points
422
+ start = shape.get("start", [0, 0])
423
+ mid = shape.get("mid", [0, 0])
424
+ end = shape.get("end", [0, 0])
425
+
426
+ # Simple approach: use bounding box of all three points
427
+ # More accurate would be to calculate the actual arc bounds
428
+ min_x = min(start[0], mid[0], end[0])
429
+ min_y = min(start[1], mid[1], end[1])
430
+ max_x = max(start[0], mid[0], end[0])
431
+ max_y = max(start[1], mid[1], end[1])
432
+
433
+ return (min_x, min_y, max_x, max_y)
434
+
435
+ elif shape_type == "polyline":
436
+ points = shape.get("points", [])
437
+ if not points:
438
+ return None
439
+
440
+ min_x = min(p[0] for p in points)
441
+ min_y = min(p[1] for p in points)
442
+ max_x = max(p[0] for p in points)
443
+ max_y = max(p[1] for p in points)
444
+
445
+ return (min_x, min_y, max_x, max_y)
446
+
447
+ elif shape_type == "text":
448
+ # Text bounding box estimation
449
+ at = shape.get("at", [0, 0])
450
+ text = shape.get("text", "")
451
+ # Rough estimation: each character is about 1.27mm wide
452
+ text_width = len(text) * cls.DEFAULT_TEXT_HEIGHT * 0.6
453
+ text_height = cls.DEFAULT_TEXT_HEIGHT
454
+
455
+ return (
456
+ at[0] - text_width / 2,
457
+ at[1] - text_height / 2,
458
+ at[0] + text_width / 2,
459
+ at[1] + text_height / 2,
460
+ )
461
+
462
+ return None
463
+
464
+ @classmethod
465
+ def _get_pin_bounds(
466
+ cls, pin: Dict[str, Any], pin_net_map: Optional[Dict[str, str]] = None
467
+ ) -> Optional[Tuple[float, float, float, float]]:
468
+ """Get bounding box for a pin including its labels."""
469
+
470
+ # Handle both formats: 'at' array or separate x/y/orientation
471
+ if "at" in pin:
472
+ at = pin.get("at", [0, 0])
473
+ x, y = at[0], at[1]
474
+ angle = at[2] if len(at) > 2 else 0
475
+ else:
476
+ # Handle the format from symbol cache
477
+ x = pin.get("x", 0)
478
+ y = pin.get("y", 0)
479
+ angle = pin.get("orientation", 0)
480
+
481
+ length = pin.get("length", cls.DEFAULT_PIN_LENGTH)
482
+
483
+ # Calculate pin endpoint based on angle
484
+ angle_rad = math.radians(angle)
485
+ end_x = x + length * math.cos(angle_rad)
486
+ end_y = y + length * math.sin(angle_rad)
487
+
488
+ # Start with pin line bounds
489
+ min_x = min(x, end_x)
490
+ min_y = min(y, end_y)
491
+ max_x = max(x, end_x)
492
+ max_y = max(y, end_y)
493
+
494
+ # Add space for pin name and number
495
+ pin_name = pin.get("name", "")
496
+ pin_number = pin.get("number", "")
497
+
498
+ # Use net name for label sizing if available (hierarchical labels show net names, not pin names)
499
+ # If no net name match, use minimal fallback to avoid oversized bounding boxes
500
+ if pin_net_map and pin_number in pin_net_map:
501
+ label_text = pin_net_map[pin_number]
502
+ logger.debug(
503
+ f" PIN {pin_number}: ✅ USING NET '{label_text}' (len={len(label_text)}), at=({x:.2f}, {y:.2f}), angle={angle}"
504
+ )
505
+ else:
506
+ # No net match - use minimal size (3 chars) instead of potentially long pin name
507
+ label_text = "XXX" # 3-character placeholder for unmatched pins
508
+ logger.debug(
509
+ f" PIN {pin_number}: ⚠️ NO MATCH, using minimal fallback (pin name was '{pin_name}'), at=({x:.2f}, {y:.2f})"
510
+ )
511
+
512
+ if label_text and label_text != "~": # ~ means no name
513
+ # Calculate text dimensions
514
+ # For horizontal text: width = char_count * char_width
515
+ name_width = (
516
+ len(label_text) * cls.DEFAULT_TEXT_HEIGHT * cls.DEFAULT_PIN_TEXT_WIDTH_RATIO
517
+ )
518
+ # For vertical text: height = char_count * char_height (characters stack vertically)
519
+ name_height = len(label_text) * cls.DEFAULT_TEXT_HEIGHT
520
+
521
+ logger.debug(
522
+ f" label_width={name_width:.2f}, label_height={name_height:.2f} (len={len(label_text)})"
523
+ )
524
+
525
+ # Adjust bounds based on pin orientation
526
+ # Labels are placed at PIN ENDPOINT with offset, extending AWAY from the component
527
+ # Pin angle indicates where the pin points (into component)
528
+ # Apply KiCad's standard pin name offset (0.508mm / 20 mils)
529
+ offset = cls.DEFAULT_PIN_NAME_OFFSET
530
+
531
+ if angle == 0: # Pin points right - label extends LEFT from endpoint
532
+ label_x = end_x - offset - name_width
533
+ logger.debug(
534
+ f" Angle 0 (Right pin): min_x {min_x:.2f} -> {label_x:.2f} (offset={offset:.3f})"
535
+ )
536
+ min_x = min(min_x, label_x)
537
+ elif angle == 180: # Pin points left - label extends RIGHT from endpoint
538
+ label_x = end_x + offset + name_width
539
+ logger.debug(
540
+ f" Angle 180 (Left pin): max_x {max_x:.2f} -> {label_x:.2f} (offset={offset:.3f})"
541
+ )
542
+ max_x = max(max_x, label_x)
543
+ elif angle == 90: # Pin points up - label extends DOWN from endpoint
544
+ label_y = end_y - offset - name_height
545
+ logger.debug(
546
+ f" Angle 90 (Up pin): min_y {min_y:.2f} -> {label_y:.2f} (offset={offset:.3f})"
547
+ )
548
+ min_y = min(min_y, label_y)
549
+ elif angle == 270: # Pin points down - label extends UP from endpoint
550
+ label_y = end_y + offset + name_height
551
+ logger.debug(
552
+ f" Angle 270 (Down pin): max_y {max_y:.2f} -> {label_y:.2f} (offset={offset:.3f})"
553
+ )
554
+ max_y = max(max_y, label_y)
555
+
556
+ # Pin numbers are typically placed near the component body
557
+ if pin_number:
558
+ num_width = (
559
+ len(pin_number) * cls.DEFAULT_PIN_NUMBER_SIZE * cls.DEFAULT_PIN_TEXT_WIDTH_RATIO
560
+ )
561
+ # Add some space for the pin number
562
+ margin = cls.DEFAULT_PIN_NUMBER_SIZE * 1.5 # Increase margin for better spacing
563
+ min_x -= margin
564
+ min_y -= margin
565
+ max_x += margin
566
+ max_y += margin
567
+
568
+ logger.debug(f" Pin bounds: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
569
+ return (min_x, min_y, max_x, max_y)
570
+
571
+ @classmethod
572
+ def _get_pin_bounds_no_labels(
573
+ cls, pin: Dict[str, Any]
574
+ ) -> Optional[Tuple[float, float, float, float]]:
575
+ """Get bounding box for a pin WITHOUT labels - for placement calculations only."""
576
+
577
+ # Handle both formats: 'at' array or separate x/y/orientation
578
+ if "at" in pin:
579
+ at = pin.get("at", [0, 0])
580
+ x, y = at[0], at[1]
581
+ angle = at[2] if len(at) > 2 else 0
582
+ else:
583
+ # Handle the format from symbol cache
584
+ x = pin.get("x", 0)
585
+ y = pin.get("y", 0)
586
+ angle = pin.get("orientation", 0)
587
+
588
+ length = pin.get("length", cls.DEFAULT_PIN_LENGTH)
589
+
590
+ # Calculate pin endpoint based on angle
591
+ angle_rad = math.radians(angle)
592
+ end_x = x + length * math.cos(angle_rad)
593
+ end_y = y + length * math.sin(angle_rad)
594
+
595
+ # Only include the pin line itself - NO labels
596
+ min_x = min(x, end_x)
597
+ min_y = min(y, end_y)
598
+ max_x = max(x, end_x)
599
+ max_y = max(y, end_y)
600
+
601
+ # Add small margin for pin graphics (circles, etc)
602
+ margin = 0.5 # Small margin for pin endpoint graphics
603
+ min_x -= margin
604
+ min_y -= margin
605
+ max_x += margin
606
+ max_y += margin
607
+
608
+ return (min_x, min_y, max_x, max_y)
@@ -0,0 +1,17 @@
1
+ """
2
+ Core interfaces for KiCAD schematic API.
3
+
4
+ This module provides abstract interfaces for the main components of the system,
5
+ enabling better separation of concerns and testability.
6
+ """
7
+
8
+ from .parser import IElementParser, ISchematicParser
9
+ from .repository import ISchematicRepository
10
+ from .resolver import ISymbolResolver
11
+
12
+ __all__ = [
13
+ "IElementParser",
14
+ "ISchematicParser",
15
+ "ISchematicRepository",
16
+ "ISymbolResolver",
17
+ ]
@@ -0,0 +1,76 @@
1
+ """
2
+ Parser interfaces for S-expression elements.
3
+
4
+ These interfaces define the contract for parsing different types of KiCAD
5
+ S-expression elements, enabling modular and testable parsing.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Protocol, Union
11
+
12
+
13
+ class IElementParser(Protocol):
14
+ """Interface for parsing individual S-expression elements."""
15
+
16
+ def can_parse(self, element: List[Any]) -> bool:
17
+ """
18
+ Check if this parser can handle the given S-expression element.
19
+
20
+ Args:
21
+ element: S-expression element (list with type as first item)
22
+
23
+ Returns:
24
+ True if this parser can handle the element type
25
+ """
26
+ ...
27
+
28
+ def parse(self, element: List[Any]) -> Optional[Dict[str, Any]]:
29
+ """
30
+ Parse an S-expression element into a dictionary representation.
31
+
32
+ Args:
33
+ element: S-expression element to parse
34
+
35
+ Returns:
36
+ Parsed element as dictionary, or None if parsing failed
37
+
38
+ Raises:
39
+ ParseError: If element is malformed or cannot be parsed
40
+ """
41
+ ...
42
+
43
+
44
+ class ISchematicParser(Protocol):
45
+ """Interface for high-level schematic parsing operations."""
46
+
47
+ def parse_file(self, filepath: Union[str, Path]) -> Dict[str, Any]:
48
+ """
49
+ Parse a complete KiCAD schematic file.
50
+
51
+ Args:
52
+ filepath: Path to the .kicad_sch file
53
+
54
+ Returns:
55
+ Complete schematic data structure
56
+
57
+ Raises:
58
+ FileNotFoundError: If file doesn't exist
59
+ ParseError: If file format is invalid
60
+ """
61
+ ...
62
+
63
+ def parse_string(self, content: str) -> Dict[str, Any]:
64
+ """
65
+ Parse schematic content from a string.
66
+
67
+ Args:
68
+ content: S-expression content as string
69
+
70
+ Returns:
71
+ Complete schematic data structure
72
+
73
+ Raises:
74
+ ParseError: If content format is invalid
75
+ """
76
+ ...