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.
- kicad_sch_api/__init__.py +2 -2
- kicad_sch_api/collections/__init__.py +21 -0
- kicad_sch_api/collections/base.py +296 -0
- kicad_sch_api/collections/components.py +422 -0
- kicad_sch_api/collections/junctions.py +378 -0
- kicad_sch_api/collections/labels.py +412 -0
- kicad_sch_api/collections/wires.py +407 -0
- kicad_sch_api/core/formatter.py +31 -0
- kicad_sch_api/core/labels.py +348 -0
- kicad_sch_api/core/nets.py +310 -0
- kicad_sch_api/core/no_connects.py +274 -0
- kicad_sch_api/core/parser.py +72 -0
- kicad_sch_api/core/schematic.py +185 -9
- kicad_sch_api/core/texts.py +343 -0
- kicad_sch_api/core/types.py +26 -0
- kicad_sch_api/geometry/__init__.py +26 -0
- kicad_sch_api/geometry/font_metrics.py +20 -0
- kicad_sch_api/geometry/symbol_bbox.py +589 -0
- kicad_sch_api/interfaces/__init__.py +17 -0
- kicad_sch_api/interfaces/parser.py +76 -0
- kicad_sch_api/interfaces/repository.py +70 -0
- kicad_sch_api/interfaces/resolver.py +117 -0
- kicad_sch_api/parsers/__init__.py +14 -0
- kicad_sch_api/parsers/base.py +148 -0
- kicad_sch_api/parsers/label_parser.py +254 -0
- kicad_sch_api/parsers/registry.py +153 -0
- kicad_sch_api/parsers/symbol_parser.py +227 -0
- kicad_sch_api/parsers/wire_parser.py +99 -0
- kicad_sch_api/symbols/__init__.py +18 -0
- kicad_sch_api/symbols/cache.py +470 -0
- kicad_sch_api/symbols/resolver.py +367 -0
- kicad_sch_api/symbols/validators.py +453 -0
- {kicad_sch_api-0.3.2.dist-info → kicad_sch_api-0.3.5.dist-info}/METADATA +1 -1
- kicad_sch_api-0.3.5.dist-info/RECORD +58 -0
- kicad_sch_api-0.3.2.dist-info/RECORD +0 -31
- {kicad_sch_api-0.3.2.dist-info → kicad_sch_api-0.3.5.dist-info}/WHEEL +0 -0
- {kicad_sch_api-0.3.2.dist-info → kicad_sch_api-0.3.5.dist-info}/entry_points.txt +0 -0
- {kicad_sch_api-0.3.2.dist-info → kicad_sch_api-0.3.5.dist-info}/licenses/LICENSE +0 -0
- {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
|
+
...
|