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,292 @@
1
+ """
2
+ Enhanced wire management with IndexRegistry integration.
3
+
4
+ Provides WireCollection using BaseCollection infrastructure with
5
+ endpoint indexing and wire geometry queries.
6
+ """
7
+
8
+ import logging
9
+ import uuid as uuid_module
10
+ from typing import Any, Dict, List, Optional, Tuple, Union
11
+
12
+ from ..core.types import Point, Wire, WireType
13
+ from .base import BaseCollection, IndexSpec, ValidationLevel
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class WireCollection(BaseCollection[Wire]):
19
+ """
20
+ Wire collection with endpoint indexing and geometry queries.
21
+
22
+ Inherits from BaseCollection for UUID indexing and adds wire-specific
23
+ functionality for endpoint queries and wire type filtering.
24
+
25
+ Features:
26
+ - Fast UUID lookup via IndexRegistry
27
+ - Multi-point wire support
28
+ - Endpoint-based queries
29
+ - Horizontal/vertical wire detection
30
+ - Lazy index rebuilding
31
+ - Batch mode support
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ wires: Optional[List[Wire]] = None,
37
+ validation_level: ValidationLevel = ValidationLevel.NORMAL,
38
+ ):
39
+ """
40
+ Initialize wire collection.
41
+
42
+ Args:
43
+ wires: Initial list of wires
44
+ validation_level: Validation level for operations
45
+ """
46
+ super().__init__(validation_level=validation_level)
47
+
48
+ # Add initial wires
49
+ if wires:
50
+ with self.batch_mode():
51
+ for wire in wires:
52
+ super().add(wire)
53
+
54
+ logger.debug(f"WireCollection initialized with {len(self)} wires")
55
+
56
+ # BaseCollection abstract method implementations
57
+ def _get_item_uuid(self, item: Wire) -> str:
58
+ """Extract UUID from wire."""
59
+ return item.uuid
60
+
61
+ def _create_item(self, **kwargs) -> Wire:
62
+ """Create a new wire (not typically used directly)."""
63
+ raise NotImplementedError("Use add() method to create wires")
64
+
65
+ def _get_index_specs(self) -> List[IndexSpec]:
66
+ """Get index specifications for wire collection."""
67
+ return [
68
+ IndexSpec(
69
+ name="uuid",
70
+ key_func=lambda w: w.uuid,
71
+ unique=True,
72
+ description="UUID index for fast lookups",
73
+ ),
74
+ ]
75
+
76
+ # Wire-specific add method
77
+ def add(
78
+ self,
79
+ start: Optional[Union[Point, Tuple[float, float]]] = None,
80
+ end: Optional[Union[Point, Tuple[float, float]]] = None,
81
+ points: Optional[List[Union[Point, Tuple[float, float]]]] = None,
82
+ wire_type: WireType = WireType.WIRE,
83
+ stroke_width: float = 0.0,
84
+ uuid: Optional[str] = None,
85
+ ) -> str:
86
+ """
87
+ Add a wire to the collection.
88
+
89
+ Args:
90
+ start: Start point (for simple wires)
91
+ end: End point (for simple wires)
92
+ points: List of points (for multi-point wires)
93
+ wire_type: Wire type (wire or bus)
94
+ stroke_width: Line width
95
+ uuid: Optional UUID (auto-generated if not provided)
96
+
97
+ Returns:
98
+ UUID of the created wire
99
+
100
+ Raises:
101
+ ValueError: If neither start/end nor points are provided
102
+ ValueError: If UUID already exists
103
+ """
104
+ # Generate UUID if not provided
105
+ if uuid is None:
106
+ uuid = str(uuid_module.uuid4())
107
+ else:
108
+ # Check for duplicate
109
+ self._ensure_indexes_current()
110
+ if self._index_registry.has_key("uuid", uuid):
111
+ raise ValueError(f"Wire with UUID '{uuid}' already exists")
112
+
113
+ # Convert points
114
+ wire_points = []
115
+ if points:
116
+ # Multi-point wire
117
+ for point in points:
118
+ if isinstance(point, tuple):
119
+ wire_points.append(Point(point[0], point[1]))
120
+ else:
121
+ wire_points.append(point)
122
+ elif start is not None and end is not None:
123
+ # Simple 2-point wire
124
+ if isinstance(start, tuple):
125
+ start = Point(start[0], start[1])
126
+ if isinstance(end, tuple):
127
+ end = Point(end[0], end[1])
128
+ wire_points = [start, end]
129
+ else:
130
+ raise ValueError("Must provide either start/end points or points list")
131
+
132
+ # Validate wire has at least 2 points
133
+ if len(wire_points) < 2:
134
+ raise ValueError("Wire must have at least 2 points")
135
+
136
+ # Create wire
137
+ wire = Wire(uuid=uuid, points=wire_points, wire_type=wire_type, stroke_width=stroke_width)
138
+
139
+ # Add to collection
140
+ super().add(wire)
141
+
142
+ logger.debug(f"Added wire: {len(wire_points)} points, UUID={uuid}")
143
+ return uuid
144
+
145
+ # Endpoint-based queries
146
+ def get_by_endpoint(
147
+ self, point: Union[Point, Tuple[float, float]], tolerance: float = 0.01
148
+ ) -> List[Wire]:
149
+ """
150
+ Find all wires with an endpoint near a given point.
151
+
152
+ Args:
153
+ point: Point to search for
154
+ tolerance: Distance tolerance for matching
155
+
156
+ Returns:
157
+ List of wires with endpoint near the point
158
+ """
159
+ if isinstance(point, tuple):
160
+ point = Point(point[0], point[1])
161
+
162
+ matching_wires = []
163
+ for wire in self._items:
164
+ # Check first and last point (endpoints)
165
+ if (wire.points[0].distance_to(point) <= tolerance or
166
+ wire.points[-1].distance_to(point) <= tolerance):
167
+ matching_wires.append(wire)
168
+
169
+ return matching_wires
170
+
171
+ def get_at_point(
172
+ self, point: Union[Point, Tuple[float, float]], tolerance: float = 0.01
173
+ ) -> List[Wire]:
174
+ """
175
+ Find all wires that pass through or near a point.
176
+
177
+ Args:
178
+ point: Point to search for
179
+ tolerance: Distance tolerance for matching
180
+
181
+ Returns:
182
+ List of wires passing through the point
183
+ """
184
+ if isinstance(point, tuple):
185
+ point = Point(point[0], point[1])
186
+
187
+ matching_wires = []
188
+ for wire in self._items:
189
+ # Check if any point in wire is near the search point
190
+ for wire_point in wire.points:
191
+ if wire_point.distance_to(point) <= tolerance:
192
+ matching_wires.append(wire)
193
+ break # Found match, move to next wire
194
+
195
+ return matching_wires
196
+
197
+ # Wire geometry queries
198
+ def get_horizontal(self) -> List[Wire]:
199
+ """
200
+ Get all horizontal wires (Y coordinates equal).
201
+
202
+ Returns:
203
+ List of horizontal wires
204
+ """
205
+ horizontal = []
206
+ for wire in self._items:
207
+ if len(wire.points) == 2:
208
+ # Simple 2-point wire
209
+ if abs(wire.points[0].y - wire.points[1].y) < 0.01:
210
+ horizontal.append(wire)
211
+
212
+ return horizontal
213
+
214
+ def get_vertical(self) -> List[Wire]:
215
+ """
216
+ Get all vertical wires (X coordinates equal).
217
+
218
+ Returns:
219
+ List of vertical wires
220
+ """
221
+ vertical = []
222
+ for wire in self._items:
223
+ if len(wire.points) == 2:
224
+ # Simple 2-point wire
225
+ if abs(wire.points[0].x - wire.points[1].x) < 0.01:
226
+ vertical.append(wire)
227
+
228
+ return vertical
229
+
230
+ def get_by_type(self, wire_type: WireType) -> List[Wire]:
231
+ """
232
+ Get all wires of a specific type.
233
+
234
+ Args:
235
+ wire_type: Wire type to filter by
236
+
237
+ Returns:
238
+ List of wires matching the type
239
+ """
240
+ return [w for w in self._items if w.wire_type == wire_type]
241
+
242
+ # Statistics
243
+ def get_statistics(self) -> Dict[str, Any]:
244
+ """
245
+ Get wire collection statistics.
246
+
247
+ Returns:
248
+ Dictionary with wire statistics
249
+ """
250
+ if not self._items:
251
+ base_stats = super().get_statistics()
252
+ base_stats.update({
253
+ "total_wires": 0,
254
+ "total_segments": 0,
255
+ "wire_count": 0,
256
+ "bus_count": 0,
257
+ "horizontal_count": 0,
258
+ "vertical_count": 0,
259
+ "avg_points_per_wire": 0,
260
+ })
261
+ return base_stats
262
+
263
+ wire_count = sum(1 for w in self._items if w.wire_type == WireType.WIRE)
264
+ bus_count = sum(1 for w in self._items if w.wire_type == WireType.BUS)
265
+ total_segments = sum(len(w.points) - 1 for w in self._items)
266
+ avg_points = sum(len(w.points) for w in self._items) / len(self._items)
267
+
268
+ horizontal = len(self.get_horizontal())
269
+ vertical = len(self.get_vertical())
270
+
271
+ base_stats = super().get_statistics()
272
+ base_stats.update({
273
+ "total_wires": len(self._items),
274
+ "total_segments": total_segments,
275
+ "wire_count": wire_count,
276
+ "bus_count": bus_count,
277
+ "horizontal_count": horizontal,
278
+ "vertical_count": vertical,
279
+ "avg_points_per_wire": avg_points,
280
+ })
281
+
282
+ return base_stats
283
+
284
+ # Compatibility methods
285
+ @property
286
+ def modified(self) -> bool:
287
+ """Check if collection has been modified (compatibility)."""
288
+ return self.is_modified
289
+
290
+ def mark_saved(self) -> None:
291
+ """Mark collection as saved (reset modified flag)."""
292
+ self.mark_clean()
@@ -1,10 +1,28 @@
1
1
  """Core kicad-sch-api functionality."""
2
2
 
3
- from .components import Component, ComponentCollection
3
+ from ..collections import Component, ComponentCollection
4
4
  from .formatter import ExactFormatter
5
5
  from .parser import SExpressionParser
6
6
  from .schematic import Schematic, create_schematic, load_schematic
7
- from .types import Junction, Label, Net, Point, SchematicSymbol, Wire
7
+ from .types import Junction, Label, Net, PinInfo, Point, SchematicSymbol, Wire
8
+ # Exception hierarchy
9
+ from .exceptions import (
10
+ KiCadSchError,
11
+ ValidationError,
12
+ ReferenceError,
13
+ LibraryError,
14
+ GeometryError,
15
+ NetError,
16
+ ParseError,
17
+ FormatError,
18
+ CollectionError,
19
+ ElementNotFoundError,
20
+ DuplicateElementError,
21
+ CollectionOperationError,
22
+ FileOperationError,
23
+ CLIError,
24
+ SchematicStateError,
25
+ )
8
26
 
9
27
  __all__ = [
10
28
  "Schematic",
@@ -16,8 +34,25 @@ __all__ = [
16
34
  "Junction",
17
35
  "Label",
18
36
  "Net",
37
+ "PinInfo",
19
38
  "SExpressionParser",
20
39
  "ExactFormatter",
21
40
  "load_schematic",
22
41
  "create_schematic",
42
+ # Exceptions
43
+ "KiCadSchError",
44
+ "ValidationError",
45
+ "ReferenceError",
46
+ "LibraryError",
47
+ "GeometryError",
48
+ "NetError",
49
+ "ParseError",
50
+ "FormatError",
51
+ "CollectionError",
52
+ "ElementNotFoundError",
53
+ "DuplicateElementError",
54
+ "CollectionOperationError",
55
+ "FileOperationError",
56
+ "CLIError",
57
+ "SchematicStateError",
23
58
  ]
@@ -0,0 +1,5 @@
1
+ """Collection base classes for schematic elements."""
2
+
3
+ from .base import BaseCollection
4
+
5
+ __all__ = ["BaseCollection"]
@@ -0,0 +1,248 @@
1
+ """
2
+ Base collection class for schematic elements.
3
+
4
+ Provides common functionality for all collection types including UUID indexing,
5
+ modification tracking, and standard collection operations.
6
+ """
7
+
8
+ import logging
9
+ from typing import Any, Callable, Dict, Generic, Iterator, List, Optional, Protocol, TypeVar
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class HasUUID(Protocol):
15
+ """Protocol for objects that have a UUID attribute."""
16
+
17
+ @property
18
+ def uuid(self) -> str:
19
+ """UUID of the object."""
20
+ ...
21
+
22
+
23
+ T = TypeVar("T", bound=HasUUID)
24
+
25
+
26
+ class BaseCollection(Generic[T]):
27
+ """
28
+ Generic base class for schematic element collections.
29
+
30
+ Provides common functionality:
31
+ - UUID-based indexing for fast lookup
32
+ - Modification tracking
33
+ - Standard collection operations (__len__, __iter__, __getitem__)
34
+ - Index rebuilding and management
35
+
36
+ Type parameter T must implement the HasUUID protocol.
37
+ """
38
+
39
+ def __init__(self, items: Optional[List[T]] = None, collection_name: str = "items") -> None:
40
+ """
41
+ Initialize base collection.
42
+
43
+ Args:
44
+ items: Initial list of items
45
+ collection_name: Name for logging (e.g., "wires", "junctions")
46
+ """
47
+ self._items: List[T] = items or []
48
+ self._uuid_index: Dict[str, int] = {}
49
+ self._modified = False
50
+ self._collection_name = collection_name
51
+
52
+ # Build UUID index
53
+ self._rebuild_index()
54
+
55
+ logger.debug(f"{collection_name} collection initialized with {len(self._items)} items")
56
+
57
+ def _rebuild_index(self) -> None:
58
+ """Rebuild UUID index for fast lookups."""
59
+ self._uuid_index = {item.uuid: i for i, item in enumerate(self._items)}
60
+
61
+ def _mark_modified(self) -> None:
62
+ """Mark collection as modified."""
63
+ self._modified = True
64
+
65
+ def is_modified(self) -> bool:
66
+ """Check if collection has been modified."""
67
+ return self._modified
68
+
69
+ def reset_modified_flag(self) -> None:
70
+ """Reset modified flag (typically after save)."""
71
+ self._modified = False
72
+
73
+ # Standard collection protocol methods
74
+ def __len__(self) -> int:
75
+ """Return number of items in collection."""
76
+ return len(self._items)
77
+
78
+ def __iter__(self) -> Iterator[T]:
79
+ """Iterate over items in collection."""
80
+ return iter(self._items)
81
+
82
+ def __getitem__(self, key: Any) -> T:
83
+ """
84
+ Get item by UUID or index.
85
+
86
+ Args:
87
+ key: UUID string or integer index
88
+
89
+ Returns:
90
+ Item at the specified location
91
+
92
+ Raises:
93
+ KeyError: If UUID not found
94
+ IndexError: If index out of range
95
+ TypeError: If key is neither string nor int
96
+ """
97
+ if isinstance(key, str):
98
+ # UUID lookup
99
+ if key not in self._uuid_index:
100
+ raise KeyError(f"Item with UUID '{key}' not found")
101
+ return self._items[self._uuid_index[key]]
102
+ elif isinstance(key, int):
103
+ # Index lookup
104
+ return self._items[key]
105
+ else:
106
+ raise TypeError(f"Key must be string (UUID) or int (index), got {type(key)}")
107
+
108
+ def __contains__(self, key: Any) -> bool:
109
+ """
110
+ Check if item exists in collection.
111
+
112
+ Args:
113
+ key: UUID string or item object
114
+
115
+ Returns:
116
+ True if item exists
117
+ """
118
+ if isinstance(key, str):
119
+ return key in self._uuid_index
120
+ elif hasattr(key, "uuid"):
121
+ return key.uuid in self._uuid_index
122
+ return False
123
+
124
+ def get(self, uuid: str) -> Optional[T]:
125
+ """
126
+ Get item by UUID.
127
+
128
+ Args:
129
+ uuid: Item UUID
130
+
131
+ Returns:
132
+ Item if found, None otherwise
133
+ """
134
+ if uuid not in self._uuid_index:
135
+ return None
136
+ return self._items[self._uuid_index[uuid]]
137
+
138
+ def remove(self, uuid: str) -> bool:
139
+ """
140
+ Remove item by UUID.
141
+
142
+ Args:
143
+ uuid: UUID of item to remove
144
+
145
+ Returns:
146
+ True if item was removed, False if not found
147
+ """
148
+ if uuid not in self._uuid_index:
149
+ return False
150
+
151
+ index = self._uuid_index[uuid]
152
+ del self._items[index]
153
+ self._rebuild_index()
154
+ self._mark_modified()
155
+
156
+ logger.debug(f"Removed item with UUID {uuid} from {self._collection_name}")
157
+ return True
158
+
159
+ def clear(self) -> None:
160
+ """Remove all items from collection."""
161
+ self._items.clear()
162
+ self._uuid_index.clear()
163
+ self._mark_modified()
164
+ logger.debug(f"Cleared all items from {self._collection_name}")
165
+
166
+ def find(self, predicate: Callable[[T], bool]) -> List[T]:
167
+ """
168
+ Find all items matching a predicate.
169
+
170
+ Args:
171
+ predicate: Function that returns True for matching items
172
+
173
+ Returns:
174
+ List of matching items
175
+ """
176
+ return [item for item in self._items if predicate(item)]
177
+
178
+ def filter(self, **criteria) -> List[T]:
179
+ """
180
+ Filter items by attribute values.
181
+
182
+ Args:
183
+ **criteria: Attribute name/value pairs to match
184
+
185
+ Returns:
186
+ List of matching items
187
+
188
+ Example:
189
+ collection.filter(wire_type=WireType.BUS, stroke_width=0.5)
190
+ """
191
+ matches = []
192
+ for item in self._items:
193
+ if all(getattr(item, key, None) == value for key, value in criteria.items()):
194
+ matches.append(item)
195
+ return matches
196
+
197
+ def get_statistics(self) -> Dict[str, Any]:
198
+ """
199
+ Get collection statistics.
200
+
201
+ Returns:
202
+ Dictionary with statistics
203
+ """
204
+ return {
205
+ "total_items": len(self._items),
206
+ "modified": self._modified,
207
+ "indexed_items": len(self._uuid_index),
208
+ }
209
+
210
+ def _add_item(self, item: T) -> None:
211
+ """
212
+ Add item to internal storage and index.
213
+
214
+ Args:
215
+ item: Item to add
216
+
217
+ Raises:
218
+ ValueError: If item UUID already exists
219
+ """
220
+ if item.uuid in self._uuid_index:
221
+ raise ValueError(f"Item with UUID '{item.uuid}' already exists")
222
+
223
+ self._items.append(item)
224
+ self._uuid_index[item.uuid] = len(self._items) - 1
225
+ self._mark_modified()
226
+
227
+ def bulk_update(self, criteria: Dict[str, Any], updates: Dict[str, Any]) -> int:
228
+ """
229
+ Update multiple items matching criteria.
230
+
231
+ Args:
232
+ criteria: Attribute name/value pairs to match
233
+ updates: Attribute name/value pairs to update
234
+
235
+ Returns:
236
+ Number of items updated
237
+ """
238
+ matching_items = self.filter(**criteria)
239
+ for item in matching_items:
240
+ for key, value in updates.items():
241
+ if hasattr(item, key):
242
+ setattr(item, key, value)
243
+
244
+ if matching_items:
245
+ self._mark_modified()
246
+
247
+ logger.debug(f"Bulk updated {len(matching_items)} items in {self._collection_name}")
248
+ return len(matching_items)
@@ -382,16 +382,43 @@ def get_component_bounding_box(
382
382
  # Calculate symbol bounding box
383
383
  symbol_bbox = SymbolBoundingBoxCalculator.calculate_bounding_box(symbol, include_properties)
384
384
 
385
- # Transform to world coordinates
386
- # TODO: Handle component rotation in the future
385
+ # Transform to world coordinates with rotation
386
+ # Apply rotation matrix to bounding box corners, then find new min/max
387
+ import math
388
+
389
+ angle_rad = math.radians(component.rotation)
390
+ cos_a = math.cos(angle_rad)
391
+ sin_a = math.sin(angle_rad)
392
+
393
+ # Get all 4 corners of the symbol bounding box
394
+ corners = [
395
+ (symbol_bbox.min_x, symbol_bbox.min_y), # Bottom-left
396
+ (symbol_bbox.max_x, symbol_bbox.min_y), # Bottom-right
397
+ (symbol_bbox.max_x, symbol_bbox.max_y), # Top-right
398
+ (symbol_bbox.min_x, symbol_bbox.max_y), # Top-left
399
+ ]
400
+
401
+ # Rotate each corner using standard 2D rotation matrix
402
+ rotated_corners = []
403
+ for x, y in corners:
404
+ rotated_x = x * cos_a - y * sin_a
405
+ rotated_y = x * sin_a + y * cos_a
406
+ rotated_corners.append((rotated_x, rotated_y))
407
+
408
+ # Find min/max of rotated corners
409
+ rotated_xs = [x for x, y in rotated_corners]
410
+ rotated_ys = [y for x, y in rotated_corners]
411
+
387
412
  world_bbox = BoundingBox(
388
- component.position.x + symbol_bbox.min_x,
389
- component.position.y + symbol_bbox.min_y,
390
- component.position.x + symbol_bbox.max_x,
391
- component.position.y + symbol_bbox.max_y,
413
+ component.position.x + min(rotated_xs),
414
+ component.position.y + min(rotated_ys),
415
+ component.position.x + max(rotated_xs),
416
+ component.position.y + max(rotated_ys),
392
417
  )
393
418
 
394
- logger.debug(f"Component {component.reference} world bbox: {world_bbox}")
419
+ logger.debug(
420
+ f"Component {component.reference} at {component.rotation}° world bbox: {world_bbox}"
421
+ )
395
422
  return world_bbox
396
423
 
397
424