kicad-sch-api 0.3.4__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/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/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/schematic.py +136 -2
- kicad_sch_api/core/texts.py +343 -0
- kicad_sch_api/core/types.py +12 -0
- kicad_sch_api/geometry/symbol_bbox.py +26 -32
- 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.4.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.4.dist-info/RECORD +0 -34
- {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.3.5.dist-info}/WHEEL +0 -0
- {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.3.5.dist-info}/entry_points.txt +0 -0
- {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.3.5.dist-info}/licenses/LICENSE +0 -0
- {kicad_sch_api-0.3.4.dist-info → kicad_sch_api-0.3.5.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Text element management for KiCAD schematics.
|
|
3
|
+
|
|
4
|
+
This module provides collection classes for managing text elements,
|
|
5
|
+
featuring fast lookup, bulk operations, and validation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union
|
|
11
|
+
|
|
12
|
+
from ..utils.validation import SchematicValidator, ValidationError, ValidationIssue
|
|
13
|
+
from .types import Point, Text
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TextElement:
|
|
19
|
+
"""
|
|
20
|
+
Enhanced wrapper for schematic text elements with modern API.
|
|
21
|
+
|
|
22
|
+
Provides intuitive access to text properties and operations
|
|
23
|
+
while maintaining exact format preservation.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, text_data: Text, parent_collection: "TextCollection"):
|
|
27
|
+
"""
|
|
28
|
+
Initialize text element wrapper.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
text_data: Underlying text data
|
|
32
|
+
parent_collection: Parent collection for updates
|
|
33
|
+
"""
|
|
34
|
+
self._data = text_data
|
|
35
|
+
self._collection = parent_collection
|
|
36
|
+
self._validator = SchematicValidator()
|
|
37
|
+
|
|
38
|
+
# Core properties with validation
|
|
39
|
+
@property
|
|
40
|
+
def uuid(self) -> str:
|
|
41
|
+
"""Text element UUID."""
|
|
42
|
+
return self._data.uuid
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def text(self) -> str:
|
|
46
|
+
"""Text content."""
|
|
47
|
+
return self._data.text
|
|
48
|
+
|
|
49
|
+
@text.setter
|
|
50
|
+
def text(self, value: str):
|
|
51
|
+
"""Set text content with validation."""
|
|
52
|
+
if not isinstance(value, str):
|
|
53
|
+
raise ValidationError(f"Text content must be string, got {type(value)}")
|
|
54
|
+
self._data.text = value
|
|
55
|
+
self._collection._mark_modified()
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def position(self) -> Point:
|
|
59
|
+
"""Text position."""
|
|
60
|
+
return self._data.position
|
|
61
|
+
|
|
62
|
+
@position.setter
|
|
63
|
+
def position(self, value: Union[Point, Tuple[float, float]]):
|
|
64
|
+
"""Set text position."""
|
|
65
|
+
if isinstance(value, tuple):
|
|
66
|
+
value = Point(value[0], value[1])
|
|
67
|
+
elif not isinstance(value, Point):
|
|
68
|
+
raise ValidationError(f"Position must be Point or tuple, got {type(value)}")
|
|
69
|
+
self._data.position = value
|
|
70
|
+
self._collection._mark_modified()
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def rotation(self) -> float:
|
|
74
|
+
"""Text rotation in degrees."""
|
|
75
|
+
return self._data.rotation
|
|
76
|
+
|
|
77
|
+
@rotation.setter
|
|
78
|
+
def rotation(self, value: float):
|
|
79
|
+
"""Set text rotation."""
|
|
80
|
+
self._data.rotation = float(value)
|
|
81
|
+
self._collection._mark_modified()
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def size(self) -> float:
|
|
85
|
+
"""Text size."""
|
|
86
|
+
return self._data.size
|
|
87
|
+
|
|
88
|
+
@size.setter
|
|
89
|
+
def size(self, value: float):
|
|
90
|
+
"""Set text size with validation."""
|
|
91
|
+
if value <= 0:
|
|
92
|
+
raise ValidationError(f"Text size must be positive, got {value}")
|
|
93
|
+
self._data.size = float(value)
|
|
94
|
+
self._collection._mark_modified()
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def exclude_from_sim(self) -> bool:
|
|
98
|
+
"""Whether text is excluded from simulation."""
|
|
99
|
+
return self._data.exclude_from_sim
|
|
100
|
+
|
|
101
|
+
@exclude_from_sim.setter
|
|
102
|
+
def exclude_from_sim(self, value: bool):
|
|
103
|
+
"""Set exclude from simulation flag."""
|
|
104
|
+
self._data.exclude_from_sim = bool(value)
|
|
105
|
+
self._collection._mark_modified()
|
|
106
|
+
|
|
107
|
+
def validate(self) -> List[ValidationIssue]:
|
|
108
|
+
"""Validate this text element."""
|
|
109
|
+
return self._validator.validate_text(self._data.__dict__)
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
112
|
+
"""Convert text element to dictionary representation."""
|
|
113
|
+
return {
|
|
114
|
+
"uuid": self.uuid,
|
|
115
|
+
"text": self.text,
|
|
116
|
+
"position": {"x": self.position.x, "y": self.position.y},
|
|
117
|
+
"rotation": self.rotation,
|
|
118
|
+
"size": self.size,
|
|
119
|
+
"exclude_from_sim": self.exclude_from_sim,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
def __str__(self) -> str:
|
|
123
|
+
"""String representation."""
|
|
124
|
+
return f"<Text '{self.text}' @ {self.position}>"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TextCollection:
|
|
128
|
+
"""
|
|
129
|
+
Collection class for efficient text element management.
|
|
130
|
+
|
|
131
|
+
Provides fast lookup, filtering, and bulk operations for schematic text elements.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
def __init__(self, texts: List[Text] = None):
|
|
135
|
+
"""
|
|
136
|
+
Initialize text collection.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
texts: Initial list of text data
|
|
140
|
+
"""
|
|
141
|
+
self._texts: List[TextElement] = []
|
|
142
|
+
self._uuid_index: Dict[str, TextElement] = {}
|
|
143
|
+
self._content_index: Dict[str, List[TextElement]] = {}
|
|
144
|
+
self._modified = False
|
|
145
|
+
|
|
146
|
+
# Add initial texts
|
|
147
|
+
if texts:
|
|
148
|
+
for text_data in texts:
|
|
149
|
+
self._add_to_indexes(TextElement(text_data, self))
|
|
150
|
+
|
|
151
|
+
logger.debug(f"TextCollection initialized with {len(self._texts)} texts")
|
|
152
|
+
|
|
153
|
+
def add(
|
|
154
|
+
self,
|
|
155
|
+
text: str,
|
|
156
|
+
position: Union[Point, Tuple[float, float]],
|
|
157
|
+
rotation: float = 0.0,
|
|
158
|
+
size: float = 1.27,
|
|
159
|
+
exclude_from_sim: bool = False,
|
|
160
|
+
text_uuid: Optional[str] = None,
|
|
161
|
+
) -> TextElement:
|
|
162
|
+
"""
|
|
163
|
+
Add a new text element to the schematic.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
text: Text content
|
|
167
|
+
position: Text position
|
|
168
|
+
rotation: Text rotation in degrees
|
|
169
|
+
size: Text size
|
|
170
|
+
exclude_from_sim: Whether to exclude from simulation
|
|
171
|
+
text_uuid: Specific UUID for text (auto-generated if None)
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Newly created TextElement
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
ValidationError: If text data is invalid
|
|
178
|
+
"""
|
|
179
|
+
# Validate inputs
|
|
180
|
+
if not isinstance(text, str) or not text.strip():
|
|
181
|
+
raise ValidationError("Text content cannot be empty")
|
|
182
|
+
|
|
183
|
+
if isinstance(position, tuple):
|
|
184
|
+
position = Point(position[0], position[1])
|
|
185
|
+
elif not isinstance(position, Point):
|
|
186
|
+
raise ValidationError(f"Position must be Point or tuple, got {type(position)}")
|
|
187
|
+
|
|
188
|
+
if size <= 0:
|
|
189
|
+
raise ValidationError(f"Text size must be positive, got {size}")
|
|
190
|
+
|
|
191
|
+
# Generate UUID if not provided
|
|
192
|
+
if not text_uuid:
|
|
193
|
+
text_uuid = str(uuid.uuid4())
|
|
194
|
+
|
|
195
|
+
# Check for duplicate UUID
|
|
196
|
+
if text_uuid in self._uuid_index:
|
|
197
|
+
raise ValidationError(f"Text UUID {text_uuid} already exists")
|
|
198
|
+
|
|
199
|
+
# Create text data
|
|
200
|
+
text_data = Text(
|
|
201
|
+
uuid=text_uuid,
|
|
202
|
+
position=position,
|
|
203
|
+
text=text,
|
|
204
|
+
rotation=rotation,
|
|
205
|
+
size=size,
|
|
206
|
+
exclude_from_sim=exclude_from_sim,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Create wrapper and add to collection
|
|
210
|
+
text_element = TextElement(text_data, self)
|
|
211
|
+
self._add_to_indexes(text_element)
|
|
212
|
+
self._mark_modified()
|
|
213
|
+
|
|
214
|
+
logger.debug(f"Added text: {text_element}")
|
|
215
|
+
return text_element
|
|
216
|
+
|
|
217
|
+
def get(self, text_uuid: str) -> Optional[TextElement]:
|
|
218
|
+
"""Get text by UUID."""
|
|
219
|
+
return self._uuid_index.get(text_uuid)
|
|
220
|
+
|
|
221
|
+
def remove(self, text_uuid: str) -> bool:
|
|
222
|
+
"""
|
|
223
|
+
Remove text by UUID.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
text_uuid: UUID of text to remove
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
True if text was removed, False if not found
|
|
230
|
+
"""
|
|
231
|
+
text_element = self._uuid_index.get(text_uuid)
|
|
232
|
+
if not text_element:
|
|
233
|
+
return False
|
|
234
|
+
|
|
235
|
+
# Remove from indexes
|
|
236
|
+
self._remove_from_indexes(text_element)
|
|
237
|
+
self._mark_modified()
|
|
238
|
+
|
|
239
|
+
logger.debug(f"Removed text: {text_element}")
|
|
240
|
+
return True
|
|
241
|
+
|
|
242
|
+
def find_by_content(self, content: str, exact: bool = True) -> List[TextElement]:
|
|
243
|
+
"""
|
|
244
|
+
Find texts by content.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
content: Content to search for
|
|
248
|
+
exact: If True, exact match; if False, substring match
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
List of matching text elements
|
|
252
|
+
"""
|
|
253
|
+
if exact:
|
|
254
|
+
return self._content_index.get(content, []).copy()
|
|
255
|
+
else:
|
|
256
|
+
matches = []
|
|
257
|
+
for text_element in self._texts:
|
|
258
|
+
if content.lower() in text_element.text.lower():
|
|
259
|
+
matches.append(text_element)
|
|
260
|
+
return matches
|
|
261
|
+
|
|
262
|
+
def filter(self, predicate: Callable[[TextElement], bool]) -> List[TextElement]:
|
|
263
|
+
"""
|
|
264
|
+
Filter texts by predicate function.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
predicate: Function that returns True for texts to include
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
List of texts matching predicate
|
|
271
|
+
"""
|
|
272
|
+
return [text for text in self._texts if predicate(text)]
|
|
273
|
+
|
|
274
|
+
def bulk_update(self, criteria: Callable[[TextElement], bool], updates: Dict[str, Any]):
|
|
275
|
+
"""
|
|
276
|
+
Update multiple texts matching criteria.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
criteria: Function to select texts to update
|
|
280
|
+
updates: Dictionary of property updates
|
|
281
|
+
"""
|
|
282
|
+
updated_count = 0
|
|
283
|
+
for text_element in self._texts:
|
|
284
|
+
if criteria(text_element):
|
|
285
|
+
for prop, value in updates.items():
|
|
286
|
+
if hasattr(text_element, prop):
|
|
287
|
+
setattr(text_element, prop, value)
|
|
288
|
+
updated_count += 1
|
|
289
|
+
|
|
290
|
+
if updated_count > 0:
|
|
291
|
+
self._mark_modified()
|
|
292
|
+
logger.debug(f"Bulk updated {updated_count} text properties")
|
|
293
|
+
|
|
294
|
+
def clear(self):
|
|
295
|
+
"""Remove all texts from collection."""
|
|
296
|
+
self._texts.clear()
|
|
297
|
+
self._uuid_index.clear()
|
|
298
|
+
self._content_index.clear()
|
|
299
|
+
self._mark_modified()
|
|
300
|
+
|
|
301
|
+
def _add_to_indexes(self, text_element: TextElement):
|
|
302
|
+
"""Add text to internal indexes."""
|
|
303
|
+
self._texts.append(text_element)
|
|
304
|
+
self._uuid_index[text_element.uuid] = text_element
|
|
305
|
+
|
|
306
|
+
# Add to content index
|
|
307
|
+
content = text_element.text
|
|
308
|
+
if content not in self._content_index:
|
|
309
|
+
self._content_index[content] = []
|
|
310
|
+
self._content_index[content].append(text_element)
|
|
311
|
+
|
|
312
|
+
def _remove_from_indexes(self, text_element: TextElement):
|
|
313
|
+
"""Remove text from internal indexes."""
|
|
314
|
+
self._texts.remove(text_element)
|
|
315
|
+
del self._uuid_index[text_element.uuid]
|
|
316
|
+
|
|
317
|
+
# Remove from content index
|
|
318
|
+
content = text_element.text
|
|
319
|
+
if content in self._content_index:
|
|
320
|
+
self._content_index[content].remove(text_element)
|
|
321
|
+
if not self._content_index[content]:
|
|
322
|
+
del self._content_index[content]
|
|
323
|
+
|
|
324
|
+
def _mark_modified(self):
|
|
325
|
+
"""Mark collection as modified."""
|
|
326
|
+
self._modified = True
|
|
327
|
+
|
|
328
|
+
# Collection interface methods
|
|
329
|
+
def __len__(self) -> int:
|
|
330
|
+
"""Return number of texts."""
|
|
331
|
+
return len(self._texts)
|
|
332
|
+
|
|
333
|
+
def __iter__(self) -> Iterator[TextElement]:
|
|
334
|
+
"""Iterate over texts."""
|
|
335
|
+
return iter(self._texts)
|
|
336
|
+
|
|
337
|
+
def __getitem__(self, index: int) -> TextElement:
|
|
338
|
+
"""Get text by index."""
|
|
339
|
+
return self._texts[index]
|
|
340
|
+
|
|
341
|
+
def __bool__(self) -> bool:
|
|
342
|
+
"""Return True if collection has texts."""
|
|
343
|
+
return len(self._texts) > 0
|
kicad_sch_api/core/types.py
CHANGED
|
@@ -386,6 +386,18 @@ class Image:
|
|
|
386
386
|
self.uuid = str(uuid4())
|
|
387
387
|
|
|
388
388
|
|
|
389
|
+
@dataclass
|
|
390
|
+
class NoConnect:
|
|
391
|
+
"""No-connect symbol in schematic."""
|
|
392
|
+
|
|
393
|
+
uuid: str
|
|
394
|
+
position: Point
|
|
395
|
+
|
|
396
|
+
def __post_init__(self):
|
|
397
|
+
if not self.uuid:
|
|
398
|
+
self.uuid = str(uuid4())
|
|
399
|
+
|
|
400
|
+
|
|
389
401
|
@dataclass
|
|
390
402
|
class Net:
|
|
391
403
|
"""Electrical net connecting components."""
|
|
@@ -59,12 +59,9 @@ class SymbolBoundingBoxCalculator:
|
|
|
59
59
|
if not symbol_data:
|
|
60
60
|
raise ValueError("Symbol data is None or empty")
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if debug_enabled:
|
|
66
|
-
print(f"\n=== CALCULATING BOUNDING BOX ===", file=sys.stderr, flush=True)
|
|
67
|
-
print(f"include_properties={include_properties}", file=sys.stderr, flush=True)
|
|
62
|
+
# Use proper logging instead of print statements
|
|
63
|
+
logger.debug("=== CALCULATING BOUNDING BOX ===")
|
|
64
|
+
logger.debug(f"include_properties={include_properties}")
|
|
68
65
|
|
|
69
66
|
min_x = float("inf")
|
|
70
67
|
min_y = float("inf")
|
|
@@ -73,7 +70,7 @@ class SymbolBoundingBoxCalculator:
|
|
|
73
70
|
|
|
74
71
|
# Process main symbol shapes (handle both 'shapes' and 'graphics' keys)
|
|
75
72
|
shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", [])
|
|
76
|
-
|
|
73
|
+
logger.debug(f"Processing {len(shapes)} main shapes")
|
|
77
74
|
for shape in shapes:
|
|
78
75
|
shape_bounds = cls._get_shape_bounds(shape)
|
|
79
76
|
if shape_bounds:
|
|
@@ -85,7 +82,7 @@ class SymbolBoundingBoxCalculator:
|
|
|
85
82
|
|
|
86
83
|
# Process pins (including their labels)
|
|
87
84
|
pins = symbol_data.get("pins", [])
|
|
88
|
-
|
|
85
|
+
logger.debug(f"Processing {len(pins)} main pins")
|
|
89
86
|
for pin in pins:
|
|
90
87
|
pin_bounds = cls._get_pin_bounds(pin, pin_net_map)
|
|
91
88
|
if pin_bounds:
|
|
@@ -123,8 +120,8 @@ class SymbolBoundingBoxCalculator:
|
|
|
123
120
|
if min_x == float("inf") or max_x == float("-inf"):
|
|
124
121
|
raise ValueError(f"No valid geometry found in symbol data")
|
|
125
122
|
|
|
126
|
-
|
|
127
|
-
|
|
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}")
|
|
128
125
|
|
|
129
126
|
# Add small margin for text that might extend beyond shapes
|
|
130
127
|
margin = 0.254 # 10 mils
|
|
@@ -162,9 +159,9 @@ class SymbolBoundingBoxCalculator:
|
|
|
162
159
|
f"Calculated bounding box: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})"
|
|
163
160
|
)
|
|
164
161
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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)
|
|
168
165
|
|
|
169
166
|
return (min_x, min_y, max_x, max_y)
|
|
170
167
|
|
|
@@ -196,8 +193,7 @@ class SymbolBoundingBoxCalculator:
|
|
|
196
193
|
if not symbol_data:
|
|
197
194
|
raise ValueError("Symbol data is None or empty")
|
|
198
195
|
|
|
199
|
-
|
|
200
|
-
print(f"\n=== CALCULATING PLACEMENT BOUNDING BOX (NO PIN LABELS) ===", file=sys.stderr, flush=True)
|
|
196
|
+
logger.debug("=== CALCULATING PLACEMENT BOUNDING BOX (NO PIN LABELS) ===")
|
|
201
197
|
|
|
202
198
|
min_x = float("inf")
|
|
203
199
|
min_y = float("inf")
|
|
@@ -206,7 +202,7 @@ class SymbolBoundingBoxCalculator:
|
|
|
206
202
|
|
|
207
203
|
# Process main symbol shapes (handle both 'shapes' and 'graphics' keys)
|
|
208
204
|
shapes = symbol_data.get("shapes", []) or symbol_data.get("graphics", [])
|
|
209
|
-
|
|
205
|
+
logger.debug(f"Processing {len(shapes)} main shapes")
|
|
210
206
|
for shape in shapes:
|
|
211
207
|
shape_bounds = cls._get_shape_bounds(shape)
|
|
212
208
|
if shape_bounds:
|
|
@@ -218,7 +214,7 @@ class SymbolBoundingBoxCalculator:
|
|
|
218
214
|
|
|
219
215
|
# Process pins WITHOUT labels (just pin endpoints)
|
|
220
216
|
pins = symbol_data.get("pins", [])
|
|
221
|
-
|
|
217
|
+
logger.debug(f"Processing {len(pins)} main pins (NO LABELS)")
|
|
222
218
|
for pin in pins:
|
|
223
219
|
pin_bounds = cls._get_pin_bounds_no_labels(pin)
|
|
224
220
|
if pin_bounds:
|
|
@@ -256,8 +252,8 @@ class SymbolBoundingBoxCalculator:
|
|
|
256
252
|
if min_x == float("inf") or max_x == float("-inf"):
|
|
257
253
|
raise ValueError(f"No valid geometry found in symbol data")
|
|
258
254
|
|
|
259
|
-
|
|
260
|
-
|
|
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}")
|
|
261
257
|
|
|
262
258
|
# Add small margin for visual spacing
|
|
263
259
|
margin = 0.635 # 25mil margin (reduced from 50mil)
|
|
@@ -274,9 +270,9 @@ class SymbolBoundingBoxCalculator:
|
|
|
274
270
|
min_y -= property_spacing + property_height # Reference above
|
|
275
271
|
max_y += property_spacing + property_height # Value below
|
|
276
272
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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)
|
|
280
276
|
|
|
281
277
|
return (min_x, min_y, max_x, max_y)
|
|
282
278
|
|
|
@@ -459,7 +455,6 @@ class SymbolBoundingBoxCalculator:
|
|
|
459
455
|
cls, pin: Dict[str, Any], pin_net_map: Optional[Dict[str, str]] = None
|
|
460
456
|
) -> Optional[Tuple[float, float, float, float]]:
|
|
461
457
|
"""Get bounding box for a pin including its labels."""
|
|
462
|
-
import sys
|
|
463
458
|
|
|
464
459
|
# Handle both formats: 'at' array or separate x/y/orientation
|
|
465
460
|
if "at" in pin:
|
|
@@ -493,11 +488,11 @@ class SymbolBoundingBoxCalculator:
|
|
|
493
488
|
# If no net name match, use minimal fallback to avoid oversized bounding boxes
|
|
494
489
|
if pin_net_map and pin_number in pin_net_map:
|
|
495
490
|
label_text = pin_net_map[pin_number]
|
|
496
|
-
|
|
491
|
+
logger.debug(f" PIN {pin_number}: ✅ USING NET '{label_text}' (len={len(label_text)}), at=({x:.2f}, {y:.2f}), angle={angle}")
|
|
497
492
|
else:
|
|
498
493
|
# No net match - use minimal size (3 chars) instead of potentially long pin name
|
|
499
494
|
label_text = "XXX" # 3-character placeholder for unmatched pins
|
|
500
|
-
|
|
495
|
+
logger.debug(f" PIN {pin_number}: ⚠️ NO MATCH, using minimal fallback (pin name was '{pin_name}'), at=({x:.2f}, {y:.2f})")
|
|
501
496
|
|
|
502
497
|
if label_text and label_text != "~": # ~ means no name
|
|
503
498
|
# Calculate text dimensions
|
|
@@ -510,7 +505,7 @@ class SymbolBoundingBoxCalculator:
|
|
|
510
505
|
# For vertical text: height = char_count * char_height (characters stack vertically)
|
|
511
506
|
name_height = len(label_text) * cls.DEFAULT_TEXT_HEIGHT
|
|
512
507
|
|
|
513
|
-
|
|
508
|
+
logger.debug(f" label_width={name_width:.2f}, label_height={name_height:.2f} (len={len(label_text)})")
|
|
514
509
|
|
|
515
510
|
# Adjust bounds based on pin orientation
|
|
516
511
|
# Labels are placed at PIN ENDPOINT with offset, extending AWAY from the component
|
|
@@ -520,19 +515,19 @@ class SymbolBoundingBoxCalculator:
|
|
|
520
515
|
|
|
521
516
|
if angle == 0: # Pin points right - label extends LEFT from endpoint
|
|
522
517
|
label_x = end_x - offset - name_width
|
|
523
|
-
|
|
518
|
+
logger.debug(f" Angle 0 (Right pin): min_x {min_x:.2f} -> {label_x:.2f} (offset={offset:.3f})")
|
|
524
519
|
min_x = min(min_x, label_x)
|
|
525
520
|
elif angle == 180: # Pin points left - label extends RIGHT from endpoint
|
|
526
521
|
label_x = end_x + offset + name_width
|
|
527
|
-
|
|
522
|
+
logger.debug(f" Angle 180 (Left pin): max_x {max_x:.2f} -> {label_x:.2f} (offset={offset:.3f})")
|
|
528
523
|
max_x = max(max_x, label_x)
|
|
529
524
|
elif angle == 90: # Pin points up - label extends DOWN from endpoint
|
|
530
525
|
label_y = end_y - offset - name_height
|
|
531
|
-
|
|
526
|
+
logger.debug(f" Angle 90 (Up pin): min_y {min_y:.2f} -> {label_y:.2f} (offset={offset:.3f})")
|
|
532
527
|
min_y = min(min_y, label_y)
|
|
533
528
|
elif angle == 270: # Pin points down - label extends UP from endpoint
|
|
534
529
|
label_y = end_y + offset + name_height
|
|
535
|
-
|
|
530
|
+
logger.debug(f" Angle 270 (Down pin): max_y {max_y:.2f} -> {label_y:.2f} (offset={offset:.3f})")
|
|
536
531
|
max_y = max(max_y, label_y)
|
|
537
532
|
|
|
538
533
|
# Pin numbers are typically placed near the component body
|
|
@@ -551,7 +546,7 @@ class SymbolBoundingBoxCalculator:
|
|
|
551
546
|
max_x += margin
|
|
552
547
|
max_y += margin
|
|
553
548
|
|
|
554
|
-
|
|
549
|
+
logger.debug(f" Pin bounds: ({min_x:.2f}, {min_y:.2f}) to ({max_x:.2f}, {max_y:.2f})")
|
|
555
550
|
return (min_x, min_y, max_x, max_y)
|
|
556
551
|
|
|
557
552
|
@classmethod
|
|
@@ -559,7 +554,6 @@ class SymbolBoundingBoxCalculator:
|
|
|
559
554
|
cls, pin: Dict[str, Any]
|
|
560
555
|
) -> Optional[Tuple[float, float, float, float]]:
|
|
561
556
|
"""Get bounding box for a pin WITHOUT labels - for placement calculations only."""
|
|
562
|
-
import sys
|
|
563
557
|
|
|
564
558
|
# Handle both formats: 'at' array or separate x/y/orientation
|
|
565
559
|
if "at" in pin:
|
|
@@ -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
|
+
...
|