kicad-sch-api 0.3.2__py3-none-any.whl → 0.3.5__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.

Potentially problematic release.


This version of kicad-sch-api might be problematic. Click here for more details.

Files changed (39) hide show
  1. kicad_sch_api/__init__.py +2 -2
  2. kicad_sch_api/collections/__init__.py +21 -0
  3. kicad_sch_api/collections/base.py +296 -0
  4. kicad_sch_api/collections/components.py +422 -0
  5. kicad_sch_api/collections/junctions.py +378 -0
  6. kicad_sch_api/collections/labels.py +412 -0
  7. kicad_sch_api/collections/wires.py +407 -0
  8. kicad_sch_api/core/formatter.py +31 -0
  9. kicad_sch_api/core/labels.py +348 -0
  10. kicad_sch_api/core/nets.py +310 -0
  11. kicad_sch_api/core/no_connects.py +274 -0
  12. kicad_sch_api/core/parser.py +72 -0
  13. kicad_sch_api/core/schematic.py +185 -9
  14. kicad_sch_api/core/texts.py +343 -0
  15. kicad_sch_api/core/types.py +26 -0
  16. kicad_sch_api/geometry/__init__.py +26 -0
  17. kicad_sch_api/geometry/font_metrics.py +20 -0
  18. kicad_sch_api/geometry/symbol_bbox.py +589 -0
  19. kicad_sch_api/interfaces/__init__.py +17 -0
  20. kicad_sch_api/interfaces/parser.py +76 -0
  21. kicad_sch_api/interfaces/repository.py +70 -0
  22. kicad_sch_api/interfaces/resolver.py +117 -0
  23. kicad_sch_api/parsers/__init__.py +14 -0
  24. kicad_sch_api/parsers/base.py +148 -0
  25. kicad_sch_api/parsers/label_parser.py +254 -0
  26. kicad_sch_api/parsers/registry.py +153 -0
  27. kicad_sch_api/parsers/symbol_parser.py +227 -0
  28. kicad_sch_api/parsers/wire_parser.py +99 -0
  29. kicad_sch_api/symbols/__init__.py +18 -0
  30. kicad_sch_api/symbols/cache.py +470 -0
  31. kicad_sch_api/symbols/resolver.py +367 -0
  32. kicad_sch_api/symbols/validators.py +453 -0
  33. {kicad_sch_api-0.3.2.dist-info → kicad_sch_api-0.3.5.dist-info}/METADATA +1 -1
  34. kicad_sch_api-0.3.5.dist-info/RECORD +58 -0
  35. kicad_sch_api-0.3.2.dist-info/RECORD +0 -31
  36. {kicad_sch_api-0.3.2.dist-info → kicad_sch_api-0.3.5.dist-info}/WHEEL +0 -0
  37. {kicad_sch_api-0.3.2.dist-info → kicad_sch_api-0.3.5.dist-info}/entry_points.txt +0 -0
  38. {kicad_sch_api-0.3.2.dist-info → kicad_sch_api-0.3.5.dist-info}/licenses/LICENSE +0 -0
  39. {kicad_sch_api-0.3.2.dist-info → kicad_sch_api-0.3.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,589 @@
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(f"After geometry processing: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
124
+ logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}")
125
+
126
+ # Add small margin for text that might extend beyond shapes
127
+ margin = 0.254 # 10 mils
128
+ min_x -= margin
129
+ min_y -= margin
130
+ max_x += margin
131
+ max_y += margin
132
+
133
+ # Include space for component properties (Reference, Value, Footprint)
134
+ if include_properties:
135
+ # Use adaptive spacing based on component dimensions
136
+ component_width = max_x - min_x
137
+ component_height = max_y - min_y
138
+
139
+ # Adaptive property width: minimum 10mm or 80% of component width
140
+ property_width = max(10.0, component_width * 0.8)
141
+ property_height = cls.DEFAULT_TEXT_HEIGHT
142
+
143
+ # Adaptive vertical spacing: minimum 5mm or 10% of component height
144
+ vertical_spacing_above = max(5.0, component_height * 0.1)
145
+ vertical_spacing_below = max(10.0, component_height * 0.15)
146
+
147
+ # Reference label above
148
+ min_y -= vertical_spacing_above + property_height
149
+
150
+ # Value and Footprint labels below
151
+ max_y += vertical_spacing_below + property_height
152
+
153
+ # Extend horizontally for property text
154
+ center_x = (min_x + max_x) / 2
155
+ min_x = min(min_x, center_x - property_width / 2)
156
+ max_x = max(max_x, center_x + property_width / 2)
157
+
158
+ logger.debug(
159
+ f"Calculated bounding box: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})"
160
+ )
161
+
162
+ logger.debug(f"FINAL BBOX: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
163
+ logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}")
164
+ logger.debug("=" * 50)
165
+
166
+ return (min_x, min_y, max_x, max_y)
167
+
168
+ @classmethod
169
+ def calculate_placement_bounding_box(
170
+ cls,
171
+ symbol_data: Dict[str, Any],
172
+ ) -> Tuple[float, float, float, float]:
173
+ """
174
+ Calculate bounding box for PLACEMENT purposes - excludes pin labels.
175
+
176
+ This method calculates a tighter bounding box that only includes:
177
+ - Component body (shapes/graphics)
178
+ - Pin endpoints (without label text)
179
+ - Small margin for component properties
180
+
181
+ Pin label text is excluded because it extends arbitrarily far based on
182
+ text length and would cause incorrect spacing in text-flow placement.
183
+
184
+ Args:
185
+ symbol_data: Dictionary containing symbol definition from KiCad library
186
+
187
+ Returns:
188
+ Tuple of (min_x, min_y, max_x, max_y) in mm
189
+
190
+ Raises:
191
+ ValueError: If symbol data is invalid or bounding box cannot be calculated
192
+ """
193
+ if not symbol_data:
194
+ raise ValueError("Symbol data is None or empty")
195
+
196
+ logger.debug("=== CALCULATING PLACEMENT BOUNDING BOX (NO PIN LABELS) ===")
197
+
198
+ min_x = float("inf")
199
+ min_y = float("inf")
200
+ max_x = float("-inf")
201
+ max_y = float("-inf")
202
+
203
+ # Process main symbol shapes (handle both 'shapes' and 'graphics' keys)
204
+ shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", [])
205
+ logger.debug(f"Processing {len(shapes)} main shapes")
206
+ for shape in shapes:
207
+ shape_bounds = cls._get_shape_bounds(shape)
208
+ if shape_bounds:
209
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
210
+ min_x = min(min_x, s_min_x)
211
+ min_y = min(min_y, s_min_y)
212
+ max_x = max(max_x, s_max_x)
213
+ max_y = max(max_y, s_max_y)
214
+
215
+ # Process pins WITHOUT labels (just pin endpoints)
216
+ pins = symbol_data.get("pins", [])
217
+ logger.debug(f"Processing {len(pins)} main pins (NO LABELS)")
218
+ for pin in pins:
219
+ pin_bounds = cls._get_pin_bounds_no_labels(pin)
220
+ if pin_bounds:
221
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
222
+ min_x = min(min_x, p_min_x)
223
+ min_y = min(min_y, p_min_y)
224
+ max_x = max(max_x, p_max_x)
225
+ max_y = max(max_y, p_max_y)
226
+
227
+ # Process sub-symbols
228
+ sub_symbols = symbol_data.get("sub_symbols", [])
229
+ for sub in sub_symbols:
230
+ # Sub-symbols can have their own shapes and pins
231
+ sub_shapes = sub.get("shapes", []) or sub.get("graphics", [])
232
+ for shape in sub_shapes:
233
+ shape_bounds = cls._get_shape_bounds(shape)
234
+ if shape_bounds:
235
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
236
+ min_x = min(min_x, s_min_x)
237
+ min_y = min(min_y, s_min_y)
238
+ max_x = max(max_x, s_max_x)
239
+ max_y = max(max_y, s_max_y)
240
+
241
+ sub_pins = sub.get("pins", [])
242
+ for pin in sub_pins:
243
+ pin_bounds = cls._get_pin_bounds_no_labels(pin)
244
+ if pin_bounds:
245
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
246
+ min_x = min(min_x, p_min_x)
247
+ min_y = min(min_y, p_min_y)
248
+ max_x = max(max_x, p_max_x)
249
+ max_y = max(max_y, p_max_y)
250
+
251
+ # Check if we found any geometry
252
+ if min_x == float("inf") or max_x == float("-inf"):
253
+ raise ValueError(f"No valid geometry found in symbol data")
254
+
255
+ logger.debug(f"After geometry processing: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
256
+ logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}")
257
+
258
+ # Add small margin for visual spacing
259
+ margin = 0.635 # 25mil margin (reduced from 50mil)
260
+ min_x -= margin
261
+ min_y -= margin
262
+ max_x += margin
263
+ max_y += margin
264
+
265
+ # Add minimal space for component properties (Reference above, Value below)
266
+ # Use adaptive spacing based on component height for better visual hierarchy
267
+ component_height = max_y - min_y
268
+ property_spacing = max(3.0, component_height * 0.15) # Adaptive: minimum 3mm or 15% of height
269
+ property_height = 1.27 # Reduced from 2.54mm
270
+ min_y -= property_spacing + property_height # Reference above
271
+ max_y += property_spacing + property_height # Value below
272
+
273
+ logger.debug(f"FINAL PLACEMENT BBOX: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
274
+ logger.debug(f" Width: {max_x - min_x:.2f}, Height: {max_y - min_y:.2f}")
275
+ logger.debug("=" * 50)
276
+
277
+ return (min_x, min_y, max_x, max_y)
278
+
279
+ @classmethod
280
+ def calculate_visual_bounding_box(
281
+ cls, symbol_data: Dict[str, Any], pin_net_map: Optional[Dict[str, str]] = None
282
+ ) -> Tuple[float, float, float, float]:
283
+ """
284
+ Calculate bounding box for visual/debug drawing (includes pin labels, no property spacing).
285
+
286
+ This shows the actual component geometry including pin labels.
287
+ Use this for drawing bounding boxes on schematics.
288
+
289
+ Args:
290
+ symbol_data: Dictionary containing symbol definition
291
+ pin_net_map: Optional mapping of pin numbers to net names (for accurate label sizing)
292
+
293
+ Returns:
294
+ Tuple of (min_x, min_y, max_x, max_y) in mm
295
+ """
296
+ # Initialize bounds
297
+ min_x = float("inf")
298
+ min_y = float("inf")
299
+ max_x = float("-inf")
300
+ max_y = float("-inf")
301
+
302
+ # Process main symbol shapes
303
+ shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", [])
304
+ for shape in shapes:
305
+ shape_bounds = cls._get_shape_bounds(shape)
306
+ if shape_bounds:
307
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
308
+ min_x = min(min_x, s_min_x)
309
+ min_y = min(min_y, s_min_y)
310
+ max_x = max(max_x, s_max_x)
311
+ max_y = max(max_y, s_max_y)
312
+
313
+ # Process pins WITH labels to get accurate visual bounds
314
+ pins = symbol_data.get("pins", [])
315
+ for pin in pins:
316
+ pin_bounds = cls._get_pin_bounds(pin, pin_net_map)
317
+ if pin_bounds:
318
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
319
+ min_x = min(min_x, p_min_x)
320
+ min_y = min(min_y, p_min_y)
321
+ max_x = max(max_x, p_max_x)
322
+ max_y = max(max_y, p_max_y)
323
+
324
+ # Process sub-symbols
325
+ sub_symbols = symbol_data.get("sub_symbols", [])
326
+ for sub in sub_symbols:
327
+ sub_shapes = sub.get("shapes", []) or sub.get("graphics", [])
328
+ for shape in sub_shapes:
329
+ shape_bounds = cls._get_shape_bounds(shape)
330
+ if shape_bounds:
331
+ s_min_x, s_min_y, s_max_x, s_max_y = shape_bounds
332
+ min_x = min(min_x, s_min_x)
333
+ min_y = min(min_y, s_min_y)
334
+ max_x = max(max_x, s_max_x)
335
+ max_y = max(max_y, s_max_y)
336
+
337
+ sub_pins = sub.get("pins", [])
338
+ for pin in sub_pins:
339
+ pin_bounds = cls._get_pin_bounds(pin, pin_net_map)
340
+ if pin_bounds:
341
+ p_min_x, p_min_y, p_max_x, p_max_y = pin_bounds
342
+ min_x = min(min_x, p_min_x)
343
+ min_y = min(min_y, p_min_y)
344
+ max_x = max(max_x, p_max_x)
345
+ max_y = max(max_y, p_max_y)
346
+
347
+ # Check if we found any geometry
348
+ if min_x == float("inf") or max_x == float("-inf"):
349
+ raise ValueError(f"No valid geometry found in symbol data")
350
+
351
+ # Add only a tiny margin for visibility (no property spacing)
352
+ margin = 0.254 # 10mil minimal margin
353
+ min_x -= margin
354
+ min_y -= margin
355
+ max_x += margin
356
+ max_y += margin
357
+
358
+ return (min_x, min_y, max_x, max_y)
359
+
360
+ @classmethod
361
+ def get_symbol_dimensions(
362
+ cls, symbol_data: Dict[str, Any], include_properties: bool = True, pin_net_map: Optional[Dict[str, str]] = None
363
+ ) -> Tuple[float, float]:
364
+ """
365
+ Get the width and height of a symbol.
366
+
367
+ Args:
368
+ symbol_data: Dictionary containing symbol definition
369
+ include_properties: Whether to include space for Reference/Value labels
370
+ pin_net_map: Optional mapping of pin numbers to net names
371
+
372
+ Returns:
373
+ Tuple of (width, height) in mm
374
+ """
375
+ min_x, min_y, max_x, max_y = cls.calculate_bounding_box(
376
+ symbol_data, include_properties, pin_net_map=pin_net_map
377
+ )
378
+ width = max_x - min_x
379
+ height = max_y - min_y
380
+ return (width, height)
381
+
382
+ @classmethod
383
+ def _get_shape_bounds(
384
+ cls, shape: Dict[str, Any]
385
+ ) -> Optional[Tuple[float, float, float, float]]:
386
+ """Get bounding box for a graphical shape."""
387
+ shape_type = shape.get("shape_type", "")
388
+
389
+ if shape_type == "rectangle":
390
+ start = shape.get("start", [0, 0])
391
+ end = shape.get("end", [0, 0])
392
+ return (
393
+ min(start[0], end[0]),
394
+ min(start[1], end[1]),
395
+ max(start[0], end[0]),
396
+ max(start[1], end[1]),
397
+ )
398
+
399
+ elif shape_type == "circle":
400
+ center = shape.get("center", [0, 0])
401
+ radius = shape.get("radius", 0)
402
+ return (
403
+ center[0] - radius,
404
+ center[1] - radius,
405
+ center[0] + radius,
406
+ center[1] + radius,
407
+ )
408
+
409
+ elif shape_type == "arc":
410
+ # For arcs, we need to consider start, mid, and end points
411
+ start = shape.get("start", [0, 0])
412
+ mid = shape.get("mid", [0, 0])
413
+ end = shape.get("end", [0, 0])
414
+
415
+ # Simple approach: use bounding box of all three points
416
+ # More accurate would be to calculate the actual arc bounds
417
+ min_x = min(start[0], mid[0], end[0])
418
+ min_y = min(start[1], mid[1], end[1])
419
+ max_x = max(start[0], mid[0], end[0])
420
+ max_y = max(start[1], mid[1], end[1])
421
+
422
+ return (min_x, min_y, max_x, max_y)
423
+
424
+ elif shape_type == "polyline":
425
+ points = shape.get("points", [])
426
+ if not points:
427
+ return None
428
+
429
+ min_x = min(p[0] for p in points)
430
+ min_y = min(p[1] for p in points)
431
+ max_x = max(p[0] for p in points)
432
+ max_y = max(p[1] for p in points)
433
+
434
+ return (min_x, min_y, max_x, max_y)
435
+
436
+ elif shape_type == "text":
437
+ # Text bounding box estimation
438
+ at = shape.get("at", [0, 0])
439
+ text = shape.get("text", "")
440
+ # Rough estimation: each character is about 1.27mm wide
441
+ text_width = len(text) * cls.DEFAULT_TEXT_HEIGHT * 0.6
442
+ text_height = cls.DEFAULT_TEXT_HEIGHT
443
+
444
+ return (
445
+ at[0] - text_width / 2,
446
+ at[1] - text_height / 2,
447
+ at[0] + text_width / 2,
448
+ at[1] + text_height / 2,
449
+ )
450
+
451
+ return None
452
+
453
+ @classmethod
454
+ def _get_pin_bounds(
455
+ cls, pin: Dict[str, Any], pin_net_map: Optional[Dict[str, str]] = None
456
+ ) -> Optional[Tuple[float, float, float, float]]:
457
+ """Get bounding box for a pin including its labels."""
458
+
459
+ # Handle both formats: 'at' array or separate x/y/orientation
460
+ if "at" in pin:
461
+ at = pin.get("at", [0, 0])
462
+ x, y = at[0], at[1]
463
+ angle = at[2] if len(at) > 2 else 0
464
+ else:
465
+ # Handle the format from symbol cache
466
+ x = pin.get("x", 0)
467
+ y = pin.get("y", 0)
468
+ angle = pin.get("orientation", 0)
469
+
470
+ length = pin.get("length", cls.DEFAULT_PIN_LENGTH)
471
+
472
+ # Calculate pin endpoint based on angle
473
+ angle_rad = math.radians(angle)
474
+ end_x = x + length * math.cos(angle_rad)
475
+ end_y = y + length * math.sin(angle_rad)
476
+
477
+ # Start with pin line bounds
478
+ min_x = min(x, end_x)
479
+ min_y = min(y, end_y)
480
+ max_x = max(x, end_x)
481
+ max_y = max(y, end_y)
482
+
483
+ # Add space for pin name and number
484
+ pin_name = pin.get("name", "")
485
+ pin_number = pin.get("number", "")
486
+
487
+ # Use net name for label sizing if available (hierarchical labels show net names, not pin names)
488
+ # If no net name match, use minimal fallback to avoid oversized bounding boxes
489
+ if pin_net_map and pin_number in pin_net_map:
490
+ label_text = pin_net_map[pin_number]
491
+ logger.debug(f" PIN {pin_number}: ✅ USING NET '{label_text}' (len={len(label_text)}), at=({x:.2f}, {y:.2f}), angle={angle}")
492
+ else:
493
+ # No net match - use minimal size (3 chars) instead of potentially long pin name
494
+ label_text = "XXX" # 3-character placeholder for unmatched pins
495
+ logger.debug(f" PIN {pin_number}: ⚠️ NO MATCH, using minimal fallback (pin name was '{pin_name}'), at=({x:.2f}, {y:.2f})")
496
+
497
+ if label_text and label_text != "~": # ~ means no name
498
+ # Calculate text dimensions
499
+ # For horizontal text: width = char_count * char_width
500
+ name_width = (
501
+ len(label_text)
502
+ * cls.DEFAULT_TEXT_HEIGHT
503
+ * cls.DEFAULT_PIN_TEXT_WIDTH_RATIO
504
+ )
505
+ # For vertical text: height = char_count * char_height (characters stack vertically)
506
+ name_height = len(label_text) * cls.DEFAULT_TEXT_HEIGHT
507
+
508
+ logger.debug(f" label_width={name_width:.2f}, label_height={name_height:.2f} (len={len(label_text)})")
509
+
510
+ # Adjust bounds based on pin orientation
511
+ # Labels are placed at PIN ENDPOINT with offset, extending AWAY from the component
512
+ # Pin angle indicates where the pin points (into component)
513
+ # Apply KiCad's standard pin name offset (0.508mm / 20 mils)
514
+ offset = cls.DEFAULT_PIN_NAME_OFFSET
515
+
516
+ if angle == 0: # Pin points right - label extends LEFT from endpoint
517
+ label_x = end_x - offset - name_width
518
+ logger.debug(f" Angle 0 (Right pin): min_x {min_x:.2f} -> {label_x:.2f} (offset={offset:.3f})")
519
+ min_x = min(min_x, label_x)
520
+ elif angle == 180: # Pin points left - label extends RIGHT from endpoint
521
+ label_x = end_x + offset + name_width
522
+ logger.debug(f" Angle 180 (Left pin): max_x {max_x:.2f} -> {label_x:.2f} (offset={offset:.3f})")
523
+ max_x = max(max_x, label_x)
524
+ elif angle == 90: # Pin points up - label extends DOWN from endpoint
525
+ label_y = end_y - offset - name_height
526
+ logger.debug(f" Angle 90 (Up pin): min_y {min_y:.2f} -> {label_y:.2f} (offset={offset:.3f})")
527
+ min_y = min(min_y, label_y)
528
+ elif angle == 270: # Pin points down - label extends UP from endpoint
529
+ label_y = end_y + offset + name_height
530
+ logger.debug(f" Angle 270 (Down pin): max_y {max_y:.2f} -> {label_y:.2f} (offset={offset:.3f})")
531
+ max_y = max(max_y, label_y)
532
+
533
+ # Pin numbers are typically placed near the component body
534
+ if pin_number:
535
+ num_width = (
536
+ len(pin_number)
537
+ * cls.DEFAULT_PIN_NUMBER_SIZE
538
+ * cls.DEFAULT_PIN_TEXT_WIDTH_RATIO
539
+ )
540
+ # Add some space for the pin number
541
+ margin = (
542
+ cls.DEFAULT_PIN_NUMBER_SIZE * 1.5
543
+ ) # Increase margin for better spacing
544
+ min_x -= margin
545
+ min_y -= margin
546
+ max_x += margin
547
+ max_y += margin
548
+
549
+ logger.debug(f" Pin bounds: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
550
+ return (min_x, min_y, max_x, max_y)
551
+
552
+ @classmethod
553
+ def _get_pin_bounds_no_labels(
554
+ cls, pin: Dict[str, Any]
555
+ ) -> Optional[Tuple[float, float, float, float]]:
556
+ """Get bounding box for a pin WITHOUT labels - for placement calculations only."""
557
+
558
+ # Handle both formats: 'at' array or separate x/y/orientation
559
+ if "at" in pin:
560
+ at = pin.get("at", [0, 0])
561
+ x, y = at[0], at[1]
562
+ angle = at[2] if len(at) > 2 else 0
563
+ else:
564
+ # Handle the format from symbol cache
565
+ x = pin.get("x", 0)
566
+ y = pin.get("y", 0)
567
+ angle = pin.get("orientation", 0)
568
+
569
+ length = pin.get("length", cls.DEFAULT_PIN_LENGTH)
570
+
571
+ # Calculate pin endpoint based on angle
572
+ angle_rad = math.radians(angle)
573
+ end_x = x + length * math.cos(angle_rad)
574
+ end_y = y + length * math.sin(angle_rad)
575
+
576
+ # Only include the pin line itself - NO labels
577
+ min_x = min(x, end_x)
578
+ min_y = min(y, end_y)
579
+ max_x = max(x, end_x)
580
+ max_y = max(y, end_y)
581
+
582
+ # Add small margin for pin graphics (circles, etc)
583
+ margin = 0.5 # Small margin for pin endpoint graphics
584
+ min_x -= margin
585
+ min_y -= margin
586
+ max_x += margin
587
+ max_y += margin
588
+
589
+ 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
+ ...